chromium/chrome/test/data/webui/tab_search/auto_tab_groups_page_test.ts

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

import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {stringToMojoString16} from 'chrome://resources/js/mojo_type_util.js';
import type {AutoTabGroupsPageElement, AutoTabGroupsResultsElement, CrInputElement, TabOrganizationSession} from 'chrome://tab-search.top-chrome/tab_search.js';
import {TabOrganizationError, TabOrganizationState, TabSearchApiProxyImpl, TabSearchSyncBrowserProxyImpl} from 'chrome://tab-search.top-chrome/tab_search.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise, isVisible, microtasksFinished} from 'chrome://webui-test/test_util.js';

import {createProfileData, createTab} from './tab_search_test_data.js';
import {TestTabSearchApiProxy} from './test_tab_search_api_proxy.js';
import {TestTabSearchSyncBrowserProxy} from './test_tab_search_sync_browser_proxy.js';

suite('AutoTabGroupsPageTest', () => {
  let autoTabGroupsPage: AutoTabGroupsPageElement;
  let autoTabGroupsResults: AutoTabGroupsResultsElement;
  let testApiProxy: TestTabSearchApiProxy;
  let testSyncProxy: TestTabSearchSyncBrowserProxy;

  function autoTabGroupsPageSetup() {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;

    testApiProxy = new TestTabSearchApiProxy();
    testApiProxy.setProfileData(createProfileData());
    const session = createSession();
    testApiProxy.setSession(session);
    TabSearchApiProxyImpl.setInstance(testApiProxy);

    testSyncProxy = new TestTabSearchSyncBrowserProxy();
    TabSearchSyncBrowserProxyImpl.setInstance(testSyncProxy);

    autoTabGroupsPage = document.createElement('auto-tab-groups-page');
    document.body.appendChild(autoTabGroupsPage);
    autoTabGroupsPage.setSessionForTesting(session);
    return microtasksFinished();
  }

  function autoTabGroupsResultsSetup() {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;

    testApiProxy = new TestTabSearchApiProxy();
    testApiProxy.setProfileData(createProfileData());
    const session = createSession();
    testApiProxy.setSession(session);
    TabSearchApiProxyImpl.setInstance(testApiProxy);

    testSyncProxy = new TestTabSearchSyncBrowserProxy();
    TabSearchSyncBrowserProxyImpl.setInstance(testSyncProxy);

    autoTabGroupsResults = document.createElement('auto-tab-groups-results');
    autoTabGroupsResults.multiTabOrganization = false;
    autoTabGroupsResults.session = session;

    document.body.appendChild(autoTabGroupsResults);
    return microtasksFinished();
  }

  function createSession(override: Partial<TabOrganizationSession> = {}):
      TabOrganizationSession {
    return Object.assign(
        {
          activeTabId: -1,
          sessionId: 1,
          state: TabOrganizationState.kNotStarted,
          organizations: [{
            organizationId: 1,
            name: stringToMojoString16('foo'),
            tabs: [
              createTab({title: 'Tab 1', url: {url: 'https://tab-1.com/'}}),
              createTab({title: 'Tab 2', url: {url: 'https://tab-2.com/'}}),
              createTab({title: 'Tab 3', url: {url: 'https://tab-3.com/'}}),
            ],
          }],
          error: TabOrganizationError.kNone,
        },
        override);
  }

  function createMultiOrganizationSession(
      count: number, override: Partial<TabOrganizationSession> = {}) {
    const organizations: Object[] = [];
    for (let i = 0; i < count; i++) {
      organizations.push(
          {
            organizationId: i,
            name: stringToMojoString16('Organization ' + i),
            tabs: [
              createTab({
                title: 'Tab 1 Organization ' + i,
                url: {url: 'https://tab-1.com/'},
              }),
              createTab({
                title: 'Tab 2 Organization ' + i,
                url: {url: 'https://tab-2.com/'},
              }),
              createTab({
                title: 'Tab 3 Organization ' + i,
                url: {url: 'https://tab-3.com/'},
              }),
            ],
          },
      );
    }

    return Object.assign(
        {
          activeTabId: -1,
          sessionId: 1,
          state: TabOrganizationState.kSuccess,
          organizations: organizations,
          error: TabOrganizationError.kNone,
        },
        override);
  }

  test('Organize tabs starts request', async () => {
    await autoTabGroupsPageSetup();
    assertEquals(0, testApiProxy.getCallCount('requestTabOrganization'));
    const notStarted = autoTabGroupsPage.shadowRoot!.querySelector(
        'auto-tab-groups-not-started');
    assertTrue(!!notStarted);
    assertTrue(isVisible(notStarted));

    const actionButton = notStarted.shadowRoot!.querySelector('cr-button');
    assertTrue(!!actionButton);
    actionButton.click();

    assertEquals(1, testApiProxy.getCallCount('requestTabOrganization'));
  });

  test('Single organization input blurs on enter', async () => {
    await autoTabGroupsResultsSetup();
    const group =
        autoTabGroupsResults.shadowRoot!.querySelector('auto-tab-groups-group');
    assertTrue(!!group);
    const input = group.shadowRoot!.querySelector<CrInputElement>(
        '#singleOrganizationInput');
    assertTrue(!!input);
    assertTrue(input.hasAttribute('focused_'));

    input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
    await input.updateComplete;
    assertFalse(input.hasAttribute('focused_'));
  });

  test('Multi organization input toggles on enter/edit', async () => {
    await autoTabGroupsResultsSetup();
    autoTabGroupsResults.multiTabOrganization = true;
    await microtasksFinished();

    function queryInput() {
      const group = autoTabGroupsResults.shadowRoot!.querySelector(
          'auto-tab-groups-group');
      assertTrue(!!group);
      return group.shadowRoot!.querySelector<HTMLElement>(
          '#multiOrganizationInput');
    }

    function queryEditButton() {
      const group = autoTabGroupsResults.shadowRoot!.querySelector(
          'auto-tab-groups-group');
      assertTrue(!!group);
      return group.shadowRoot!.querySelector<HTMLElement>('.icon-edit');
    }

    let input = queryInput();
    assertTrue(!!input);
    assertTrue(isVisible(input));

    input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
    await microtasksFinished();

    assertFalse(!!queryInput());

    const editButton = queryEditButton();
    assertTrue(!!editButton);
    assertTrue(isVisible(editButton));

    editButton.click();
    await microtasksFinished();

    input = queryInput();
    assertTrue(!!input);
    assertTrue(isVisible(input));
    assertFalse(!!queryEditButton());
  });

  test('Tab close removes from tab list', async () => {
    await autoTabGroupsPageSetup();

    testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
        createSession({state: TabOrganizationState.kSuccess}));
    await microtasksFinished();

    const results =
        autoTabGroupsPage.shadowRoot!.querySelector('auto-tab-groups-results');
    assertTrue(!!results);
    const group = results.shadowRoot!.querySelector('auto-tab-groups-group');
    assertTrue(!!group);

    assertEquals(0, testApiProxy.getCallCount('removeTabFromOrganization'));

    const tabRows = group.shadowRoot!.querySelectorAll('tab-search-item');
    assertTrue(!!tabRows);
    assertEquals(3, tabRows.length);

    const cancelButton =
        tabRows[0]!.shadowRoot!.querySelector('cr-icon-button');
    assertTrue(!!cancelButton);
    cancelButton.click();

    assertEquals(1, testApiProxy.getCallCount('removeTabFromOrganization'));
  });

  test('Arrow keys traverse focus in results list', async () => {
    await autoTabGroupsResultsSetup();

    const group =
        autoTabGroupsResults.shadowRoot!.querySelector('auto-tab-groups-group');
    assertTrue(!!group);
    const tabRows = group.shadowRoot!.querySelectorAll('tab-search-item');
    assertTrue(!!tabRows);
    assertEquals(3, tabRows.length);

    const closeButton0 =
        tabRows[0]!.shadowRoot!.querySelector(`cr-icon-button`);
    assertTrue(!!closeButton0);
    const closeButton1 =
        tabRows[1]!.shadowRoot!.querySelector(`cr-icon-button`);
    assertTrue(!!closeButton1);
    const closeButton2 =
        tabRows[2]!.shadowRoot!.querySelector(`cr-icon-button`);
    assertTrue(!!closeButton2);

    closeButton0.focus();

    assertTrue(closeButton0.matches(':focus'));
    assertFalse(closeButton1.matches(':focus'));
    assertFalse(closeButton2.matches(':focus'));

    group.$.selector.dispatchEvent(
        new KeyboardEvent('keydown', {key: 'ArrowUp'}));
    await microtasksFinished();

    assertFalse(closeButton0.matches(':focus'));
    assertFalse(closeButton1.matches(':focus'));
    assertTrue(closeButton2.matches(':focus'));

    group.$.selector.dispatchEvent(
        new KeyboardEvent('keydown', {key: 'ArrowDown'}));
    await microtasksFinished();

    assertTrue(closeButton0.matches(':focus'));
    assertFalse(closeButton1.matches(':focus'));
    assertFalse(closeButton2.matches(':focus'));
  });

  test('Arrow keys traverse focus in footer', async () => {
    await autoTabGroupsResultsSetup();

    const focusableElement0 = autoTabGroupsResults.$.learnMore;
    const focusableElement1 = autoTabGroupsResults.$.feedbackButtons.$.thumbsUp;
    const focusableElement2 =
        autoTabGroupsResults.$.feedbackButtons.$.thumbsDown;
    focusableElement0.focus();

    assertTrue(focusableElement0.matches(':focus'));
    assertFalse(focusableElement1.matches(':focus'));
    assertFalse(focusableElement2.matches(':focus'));

    const feedback =
        autoTabGroupsResults.shadowRoot!.querySelector('.feedback');
    assertTrue(!!feedback);
    feedback.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowLeft'}));

    assertFalse(focusableElement0.matches(':focus'));
    assertFalse(focusableElement1.matches(':focus'));
    assertTrue(focusableElement2.matches(':focus'));

    feedback.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight'}));

    assertTrue(focusableElement0.matches(':focus'));
    assertFalse(focusableElement1.matches(':focus'));
    assertFalse(focusableElement2.matches(':focus'));
  });

  test('Single organization create group accepts organization', async () => {
    await autoTabGroupsPageSetup();

    testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
        createSession({state: TabOrganizationState.kSuccess}));

    assertEquals(0, testApiProxy.getCallCount('acceptTabOrganization'));

    const results =
        autoTabGroupsPage.shadowRoot!.querySelector('auto-tab-groups-results');
    assertTrue(!!results);
    const group = results.shadowRoot!.querySelector('auto-tab-groups-group');
    assertTrue(!!group);
    const actions =
        group.shadowRoot!.querySelector('auto-tab-groups-results-actions');
    assertTrue(!!actions);
    const createGroupButton = actions.shadowRoot!.querySelector('cr-button');
    assertTrue(!!createGroupButton);
    createGroupButton.click();
    await microtasksFinished();

    assertEquals(1, testApiProxy.getCallCount('acceptTabOrganization'));
  });

  test(
      'Multi organization create groups accepts all organizations',
      async () => {
        loadTimeData.overrideValues({
          multiTabOrganizationEnabled: true,
        });

        await autoTabGroupsPageSetup();

        const organizationCount = 3;
        testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
            createMultiOrganizationSession(organizationCount));
        await microtasksFinished();

        assertEquals(0, testApiProxy.getCallCount('acceptTabOrganization'));

        const results = autoTabGroupsPage.shadowRoot!.querySelector(
            'auto-tab-groups-results');
        assertTrue(!!results);
        const actions = results.shadowRoot!.querySelector(
            'auto-tab-groups-results-actions');
        assertTrue(!!actions);
        const createGroupsButton =
            actions.shadowRoot!.querySelector<HTMLElement>('#createButton');
        assertTrue(!!createGroupsButton);
        createGroupsButton.click();
        await microtasksFinished();

        assertEquals(
            organizationCount,
            testApiProxy.getCallCount('acceptTabOrganization'));
      });

  test('Group cancel button rejects organization', async () => {
    loadTimeData.overrideValues({
      multiTabOrganizationEnabled: true,
    });

    await autoTabGroupsPageSetup();

    testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
        createMultiOrganizationSession(
            3, {state: TabOrganizationState.kSuccess}));
    await microtasksFinished();

    assertEquals(0, testApiProxy.getCallCount('rejectTabOrganization'));

    const results =
        autoTabGroupsPage.shadowRoot!.querySelector('auto-tab-groups-results');
    assertTrue(!!results);
    const group = results.shadowRoot!.querySelector('auto-tab-groups-group');
    assertTrue(!!group);
    const cancelButton =
        group.shadowRoot!.querySelector<HTMLElement>('#rejectButton');
    assertTrue(!!cancelButton);
    cancelButton.click();
    await microtasksFinished();

    assertEquals(1, testApiProxy.getCallCount('rejectTabOrganization'));
  });

  test('Clear rejects session', async () => {
    loadTimeData.overrideValues({
      multiTabOrganizationEnabled: true,
    });

    await autoTabGroupsPageSetup();

    testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
        createSession({state: TabOrganizationState.kSuccess}));
    await microtasksFinished();

    assertEquals(0, testApiProxy.getCallCount('rejectSession'));

    const results =
        autoTabGroupsPage.shadowRoot!.querySelector('auto-tab-groups-results');
    assertTrue(!!results);
    const actions =
        results.shadowRoot!.querySelector('auto-tab-groups-results-actions');
    assertTrue(!!actions);
    const clearButton =
        actions.shadowRoot!.querySelector<HTMLElement>('#clearButton');
    assertTrue(!!clearButton);
    clearButton.click();
    await microtasksFinished();

    assertEquals(1, testApiProxy.getCallCount('rejectSession'));
  });

  test('Tip action activates on Enter', async () => {
    loadTimeData.overrideValues({
      showTabOrganizationFRE: true,
    });

    await autoTabGroupsPageSetup();

    testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
        createSession({
          state: TabOrganizationState.kFailure,
          error: TabOrganizationError.kGeneric,
        }));

    assertEquals(0, testApiProxy.getCallCount('startTabGroupTutorial'));

    const failure =
        autoTabGroupsPage.shadowRoot!.querySelector('auto-tab-groups-failure');
    assertTrue(!!failure);
    const links = failure.shadowRoot!.querySelectorAll<HTMLElement>(
        '.auto-tab-groups-link');
    assertEquals(1, links.length);
    const tipAction = links[0]!;
    tipAction.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
    await microtasksFinished();

    assertEquals(1, testApiProxy.getCallCount('startTabGroupTutorial'));
  });

  test('Active tab missing from organization shows error', async () => {
    const errorString = 'error';
    const successString = 'success';
    loadTimeData.overrideValues({
      successMissingActiveTabTitle: errorString,
      successTitle: successString,
    });
    await autoTabGroupsResultsSetup();
    autoTabGroupsResults.session = createSession({
      activeTabId: 4,
      organizations: [{
        organizationId: 1,
        name: stringToMojoString16('foo'),
        firstNewTabIndex: 0,
        tabs: [
          createTab(
              {title: 'Tab 1', url: {url: 'https://tab-1.com/'}, tabId: 1}),
          createTab(
              {title: 'Tab 2', url: {url: 'https://tab-2.com/'}, tabId: 2}),
          createTab(
              {title: 'Tab 3', url: {url: 'https://tab-3.com/'}, tabId: 3}),
        ],
      }],
    });
    await microtasksFinished();

    const header = autoTabGroupsResults.$.header;
    assertEquals(errorString, header.textContent!.trim());
  });

  test('Active tab present in organization does not show error', async () => {
    const errorString = 'error';
    const successString = 'success';
    loadTimeData.overrideValues({
      successMissingActiveTabTitle: errorString,
      successTitle: successString,
    });
    await autoTabGroupsResultsSetup();
    autoTabGroupsResults.session = createSession({
      activeTabId: 2,
      organizations: [{
        organizationId: 1,
        name: stringToMojoString16('foo'),
        firstNewTabIndex: 0,
        tabs: [
          createTab(
              {title: 'Tab 1', url: {url: 'https://tab-1.com/'}, tabId: 1}),
          createTab(
              {title: 'Tab 2', url: {url: 'https://tab-2.com/'}, tabId: 2}),
          createTab(
              {title: 'Tab 3', url: {url: 'https://tab-3.com/'}, tabId: 3}),
        ],
      }],
    });
    await microtasksFinished();

    const header = autoTabGroupsResults.$.header;
    assertEquals(successString, header.textContent!.trim());
  });

  test('Announces not started header on state change', async () => {
    const notStartedHeader = 'Not Started';
    loadTimeData.overrideValues({
      notStartedTitleFRE: notStartedHeader,
    });
    const announcementPromise =
        eventToPromise('cr-a11y-announcer-messages-sent', document.body);
    await autoTabGroupsPageSetup();

    const announcement = await announcementPromise;
    assertTrue(!!announcement);
    assertTrue(announcement.detail.messages.includes(notStartedHeader));
  });

  test('Announces in progress header on state change', async () => {
    const inProgressHeader = 'In Progress';
    loadTimeData.overrideValues({
      inProgressTitle: inProgressHeader,
    });
    await autoTabGroupsPageSetup();

    const announcementPromise =
        eventToPromise('cr-a11y-announcer-messages-sent', document.body);
    testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
        createSession({state: TabOrganizationState.kInProgress}));

    const announcement = await announcementPromise;
    assertTrue(!!announcement);
    assertTrue(announcement.detail.messages.includes(inProgressHeader));
  });

  test('Announces results header on state change', async () => {
    const resultsHeader = 'Results';
    loadTimeData.overrideValues({
      successTitleSingle: resultsHeader,
    });
    await autoTabGroupsPageSetup();

    const announcementPromise =
        eventToPromise('cr-a11y-announcer-messages-sent', document.body);
    testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
        createSession({state: TabOrganizationState.kSuccess}));

    const announcement = await announcementPromise;
    assertTrue(!!announcement);
    assertTrue(announcement.detail.messages.includes(resultsHeader));
  });

  test('Announces failure header on state change', async () => {
    const failureHeader = 'Failure';
    loadTimeData.overrideValues({
      failureTitleGeneric: failureHeader,
    });
    await autoTabGroupsPageSetup();

    const announcementPromise =
        eventToPromise('cr-a11y-announcer-messages-sent', document.body);
    testApiProxy.getCallbackRouterRemote().tabOrganizationSessionUpdated(
        createSession({
          state: TabOrganizationState.kFailure,
          error: TabOrganizationError.kGeneric,
        }));

    const announcement = await announcementPromise;
    assertTrue(!!announcement);
    assertTrue(announcement.detail.messages.includes(failureHeader));
  });
});