chromium/chrome/test/data/webui/cr_components/history_embeddings/history_embeddings_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/strings.m.js';
import 'chrome://resources/cr_components/history_embeddings/history_embeddings.js';

import {CrFeedbackOption} from '//resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import {HistoryEmbeddingsBrowserProxyImpl} from 'chrome://resources/cr_components/history_embeddings/browser_proxy.js';
import type {HistoryEmbeddingsElement} from 'chrome://resources/cr_components/history_embeddings/history_embeddings.js';
import {PageHandlerRemote, UserFeedback} from 'chrome://resources/cr_components/history_embeddings/history_embeddings.mojom-webui.js';
import type {SearchQuery, SearchResultItem} from 'chrome://resources/cr_components/history_embeddings/history_embeddings.mojom-webui.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {assertDeepEquals, assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {flushTasks} from 'chrome://webui-test/polymer_test_util.js';
import {TestMock} from 'chrome://webui-test/test_mock.js';
import {eventToPromise, isVisible} from 'chrome://webui-test/test_util.js';

suite('cr-history-embeddings', () => {
  let element: HistoryEmbeddingsElement;
  let handler: TestMock<PageHandlerRemote>&PageHandlerRemote;

  const mockResults: SearchResultItem[] = [
    {
      title: 'Google',
      url: {url: 'http://google.com'},
      urlForDisplay: 'google.com',
      relativeTime: '2 hours ago',
      sourcePassage: 'Google description',
      lastUrlVisitTimestamp: 1000,
      answerData: null,
    },
    {
      title: 'Youtube',
      url: {url: 'http://youtube.com'},
      urlForDisplay: 'youtube.com',
      relativeTime: '4 hours ago',
      sourcePassage: 'Youtube description',
      lastUrlVisitTimestamp: 2000,
      answerData: null,
    },
  ];

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

    handler = TestMock.fromClass(PageHandlerRemote);

    const mockSearch = handler.search;
    handler.search = (query: SearchQuery) => {
      mockSearch(query);
      // Simulate a response from browser. If some other results are needed,
      // consider calling this directly from tests instead.
      element.searchResultChangedForTesting({
        query: query.query,
        answer: '',
        items: [...mockResults],
      });
    };

    HistoryEmbeddingsBrowserProxyImpl.setInstance(
        new HistoryEmbeddingsBrowserProxyImpl(handler));

    element = document.createElement('cr-history-embeddings');
    document.body.appendChild(element);
    element.overrideLoadingStateMinimumMsForTesting(0);

    element.numCharsForQuery = 21;
    element.searchQuery = 'some query';
    await handler.whenCalled('search');
    element.overrideQueryResultMinAgeForTesting(0);
    return flushTasks();
  });

  test('Searches', async () => {
    assertEquals('some query', handler.getArgs('search')[0].query);
  });

  test('DisplaysHeading', async () => {
    loadTimeData.overrideValues({
      historyEmbeddingsHeading: 'searched for "$1"',
      historyEmbeddingsHeadingLoading: 'loading results for "$1"',
    });
    element.searchQuery = 'my query';
    assertEquals(
        'loading results for "my query"',
        element.$.heading.textContent!.trim());
    await handler.whenCalled('search');
    await flushTasks();
    assertEquals(
        'searched for "my query"', element.$.heading.textContent!.trim());
  });

  test('DisplaysLoading', async () => {
    element.overrideLoadingStateMinimumMsForTesting(100);
    element.searchQuery = 'my new query';
    await handler.whenCalled('search');
    assertTrue(
        isVisible(element.$.loading),
        'Loading state should be visible even if search immediately resolved');

    await new Promise(resolve => setTimeout(resolve, 100));
    assertFalse(
        isVisible(element.$.loading),
        'Loading state should disappear once the minimum of 100ms is over');
  });

  test('DisplaysResults', async () => {
    const resultsElements =
        element.shadowRoot!.querySelectorAll('cr-url-list-item');
    assertEquals(2, resultsElements.length);
    assertEquals('Google', resultsElements[0]!.title);
    assertEquals('Youtube', resultsElements[1]!.title);
  });

  test('FiresClick', async () => {
    const resultsElements =
        element.shadowRoot!.querySelectorAll('cr-url-list-item');
    const resultClickEventPromise = eventToPromise('result-click', element);
    resultsElements[0]!.click();
    const resultClickEvent = await resultClickEventPromise;
    assertEquals(mockResults[0], resultClickEvent.detail);
  });

  test('FiresClickOnMoreActions', async () => {
    const moreActionsIconButtons =
        element.shadowRoot!.querySelectorAll<HTMLElement>(
            'cr-url-list-item cr-icon-button');
    moreActionsIconButtons[0]!.click();
    await flushTasks();

    // Clicking on the more actions button for the first item should load
    // the cr-action-menu and open it.
    const moreActionsMenu = element.shadowRoot!.querySelector('cr-action-menu');
    assertTrue(!!moreActionsMenu);
    assertTrue(moreActionsMenu.open);

    const actionMenuItems = moreActionsMenu.querySelectorAll('button');
    assertEquals(2, actionMenuItems.length);

    // Clicking on the first button should fire the 'more-from-site-click' event
    // with the first item's model, and then close the menu.
    const moreFromSiteEventPromise =
        eventToPromise('more-from-site-click', element);
    const moreFromSiteItem =
        moreActionsMenu.querySelector<HTMLElement>('#moreFromSiteOption')!;
    moreFromSiteItem.click();
    const moreFromSiteEvent = await moreFromSiteEventPromise;
    assertEquals(mockResults[0], moreFromSiteEvent.detail);
    assertFalse(moreActionsMenu.open);

    // Clicking on the second button should fire the 'remove-item-click' event
    // with the second item's model, and then close the menu.
    moreActionsIconButtons[1]!.click();
    assertTrue(moreActionsMenu.open);
    const removeItemEventPromise = eventToPromise('remove-item-click', element);
    const removeItemItem =
        moreActionsMenu.querySelector<HTMLElement>('#removeFromHistoryOption')!;
    removeItemItem.click();
    await flushTasks();
    const removeItemEvent = await removeItemEventPromise;
    assertEquals(mockResults[1], removeItemEvent.detail);
    assertFalse(moreActionsMenu.open);
  });

  test('RemovesItemsFromFrontend', async () => {
    const moreActionsIconButtons =
        element.shadowRoot!.querySelectorAll<HTMLElement>(
            'cr-url-list-item cr-icon-button');

    // Open the 'more actions' menu for the first result and remove it.
    moreActionsIconButtons[0]!.click();
    element.shadowRoot!.querySelector<HTMLElement>(
                           '#removeFromHistoryOption')!.click();
    await flushTasks();

    // There is still 1 result left so it should still be visible.
    assertFalse(element.isEmpty);
    assertTrue(isVisible(element));
    assertEquals(
        1, element.shadowRoot!.querySelectorAll('cr-url-list-item').length);

    // Open the 'more actions' menu for the last result and remove it.
    moreActionsIconButtons[0]!.click();
    element.shadowRoot!.querySelector<HTMLElement>(
                           '#removeFromHistoryOption')!.click();
    await flushTasks();

    // No results left.
    assertTrue(element.isEmpty);
    assertFalse(isVisible(element));
  });

  test('SetsUserFeedback', async () => {
    assertEquals(
        CrFeedbackOption.UNSPECIFIED, element.$.feedbackButtons.selectedOption,
        'defaults to unspecified');

    function dispatchFeedbackOptionChange(option: CrFeedbackOption) {
      element.$.feedbackButtons.dispatchEvent(
          new CustomEvent('selected-option-changed', {
            bubbles: true,
            composed: true,
            detail: {value: option},
          }));
    }

    dispatchFeedbackOptionChange(CrFeedbackOption.THUMBS_DOWN);
    assertEquals(
        UserFeedback.kUserFeedbackNegative,
        await handler.whenCalled('setUserFeedback'));
    assertEquals(
        CrFeedbackOption.THUMBS_DOWN, element.$.feedbackButtons.selectedOption);
    handler.reset();

    dispatchFeedbackOptionChange(CrFeedbackOption.THUMBS_UP);
    assertEquals(
        UserFeedback.kUserFeedbackPositive,
        await handler.whenCalled('setUserFeedback'));
    assertEquals(
        CrFeedbackOption.THUMBS_UP, element.$.feedbackButtons.selectedOption);
    handler.reset();

    dispatchFeedbackOptionChange(CrFeedbackOption.UNSPECIFIED);
    assertEquals(
        UserFeedback.kUserFeedbackUnspecified,
        await handler.whenCalled('setUserFeedback'));
    assertEquals(
        CrFeedbackOption.UNSPECIFIED, element.$.feedbackButtons.selectedOption);
    handler.reset();

    // Search again with new query.
    element.searchQuery = 'new query';

    await handler.whenCalled('search');
    await flushTasks();
    assertEquals(
        CrFeedbackOption.UNSPECIFIED, element.$.feedbackButtons.selectedOption,
        'defaults back to unspecified when there is a new set of results');
  });

  test('SendsQualityLog', async () => {
    // Click on the second result.
    const resultsElements =
        element.shadowRoot!.querySelectorAll('cr-url-list-item');
    resultsElements[1]!.click();

    // Perform a new search, which should log the previous result.
    element.searchQuery = 'some new query';
    await handler.whenCalled('search');
    let [clickedIndices, numChars] = await handler.whenCalled('sendQualityLog');
    assertDeepEquals([1], clickedIndices);
    assertEquals(21, numChars);
    handler.resetResolver('sendQualityLog');

    // Override the minimum result age and ensure transient results are not
    // logged. Only after the 100ms passes and another search is performed
    // should the quality log be sent.
    element.overrideLoadingStateMinimumMsForTesting(50);
    element.overrideQueryResultMinAgeForTesting(100);
    element.numCharsForQuery = 25;
    element.searchQuery = 'some newer que';
    await handler.whenCalled('search');
    // Perform another query immediately and then wait 100ms. This will ensure
    // that this is the result set that the quality log is sent for.
    element.numCharsForQuery = 30;
    element.searchQuery = 'some newer query';
    await handler.whenCalled('search');
    await new Promise(resolve => setTimeout(resolve, 50));   // Loading state.
    await new Promise(resolve => setTimeout(resolve, 100));  // Result age.

    // A new query should now send quality log for the last result.
    element.numCharsForQuery = 50;
    element.searchQuery = 'some even newer query';
    await handler.whenCalled('search');
    assertEquals(1, handler.getCallCount('sendQualityLog'));

    // Updating the numCharsForQuery property should have no impact.
    element.numCharsForQuery = 90;

    [clickedIndices, numChars] = await handler.whenCalled('sendQualityLog');
    assertDeepEquals([], clickedIndices);
    assertEquals(30, numChars);
  });

  test('SendsQualityLogOnDisconnect', async () => {
    element.remove();
    const [clickedIndices, numChars] =
        await handler.whenCalled('sendQualityLog');
    assertDeepEquals([], clickedIndices);
    assertEquals(21, numChars);
  });

  test('SendsQualityLogOnBeforeUnload', async () => {
    window.dispatchEvent(new Event('beforeunload'));
    const [clickedIndices, numChars] =
        await handler.whenCalled('sendQualityLog');
    assertDeepEquals([], clickedIndices);
    assertEquals(21, numChars);
  });

  test('ForceFlushesQualityLogOnBeforeUnload', async () => {
    handler.resetResolver('sendQualityLog');
    // Make the min age really long so we can test a beforeunload happening
    // before results are considered 'stable'.
    element.overrideQueryResultMinAgeForTesting(100000);

    window.dispatchEvent(new Event('beforeunload'));

    // Log should immediately be sent without having to wait the 100000ms.
    assertEquals(1, handler.getCallCount('sendQualityLog'));
  });

  test('SendsQualityLogOnlyOnce', async () => {
    // Click on a couple of the results.
    const resultsElements =
        element.shadowRoot!.querySelectorAll('cr-url-list-item');
    resultsElements[0]!.click();
    resultsElements[1]!.click();

    // Multiple events that can cause logs.
    element.searchQuery = 'some newer query';
    await handler.whenCalled('search');
    window.dispatchEvent(new Event('beforeunload'));
    element.remove();

    const [clickedIndices, numChars] =
        await handler.whenCalled('sendQualityLog');
    assertDeepEquals([0, 1], clickedIndices);
    assertEquals(21, numChars);
    assertEquals(1, handler.getCallCount('sendQualityLog'));
  });
});