chromium/chrome/test/data/webui/extensions/activity_log_history_test.ts

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

/** @fileoverview Suite of tests for activity-log-history. */

import type {ActivityLogHistoryElement} from 'chrome://extensions/extensions.js';
import {ActivityLogPageState} from 'chrome://extensions/extensions.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';

import {TestService} from './test_service.js';
import {testVisible} from './test_util.js';

suite('ExtensionsActivityLogHistoryTest', function() {
  /**
   * Backing extension id, same id as the one in createExtensionInfo
   */
  const EXTENSION_ID: string = 'a'.repeat(32);

  const testActivities: chrome.activityLogPrivate.ActivityResultSet = {
    activities: [
      {
        activityId: '299',
        activityType: chrome.activityLogPrivate.ExtensionActivityType.API_CALL,
        apiCall: 'i18n.getUILanguage',
        args: 'null',
        count: 10,
        extensionId: EXTENSION_ID,
        time: 1541203132002.664,
      },
      {
        activityId: '309',
        activityType:
            chrome.activityLogPrivate.ExtensionActivityType.DOM_ACCESS,
        apiCall: 'Storage.getItem',
        args: 'null',
        count: 35,
        extensionId: EXTENSION_ID,
        other: {
          domVerb: chrome.activityLogPrivate.ExtensionActivityDomVerb.METHOD,
        },
        pageTitle: 'Test Extension',
        pageUrl: `chrome-extension://${EXTENSION_ID}/index.html`,
        time: 1541203131994.837,
      },
      {
        activityId: '308',
        activityType:
            chrome.activityLogPrivate.ExtensionActivityType.DOM_ACCESS,
        apiCall: 'Storage.setItem',
        args: 'null',
        count: 10,
        extensionId: EXTENSION_ID,
        pageUrl: `chrome-extension://${EXTENSION_ID}/index.html`,
        time: 1541203131994.837,
      },
      {
        activityId: '301',
        activityType: chrome.activityLogPrivate.ExtensionActivityType.API_CALL,
        apiCall: 'i18n.getUILanguage',
        args: 'null',
        count: 30,
        extensionId: EXTENSION_ID,
        time: 1541203172002.664,
      },
    ],
  };

  // The first two activities of |testActivities|,
  const testExportActivities: chrome.activityLogPrivate.ActivityResultSet = {
    activities: testActivities.activities.slice(0, 2),
  };

  // Sample activities representing content script invocations. Activities with
  // missing args will not be processed.
  const testContentScriptActivities:
      chrome.activityLogPrivate.ActivityResultSet = {
    activities: [
      {
        activityId: '288',
        activityType:
            chrome.activityLogPrivate.ExtensionActivityType.CONTENT_SCRIPT,
        apiCall: '',
        args: `["script1.js","script2.js"]`,
        count: 1,
        extensionId: EXTENSION_ID,
        pageTitle: 'Test Extension',
        pageUrl: 'https://www.google.com/search',
      },
      {
        activityId: '290',
        activityType:
            chrome.activityLogPrivate.ExtensionActivityType.CONTENT_SCRIPT,
        apiCall: '',
        count: 1,
        extensionId: EXTENSION_ID,
        pageTitle: 'Test Extension',
        pageUrl: 'https://www.google.com/search',
      },
    ],
  };

  // Sample activities representing web requests. Activities with valid fields
  // in other.webRequest should be split into multiple entries; one for every
  // field. Activities with empty fields will have the group name be just the
  // web request API call.
  const testWebRequestActivities:
      chrome.activityLogPrivate.ActivityResultSet = {
    activities: [
      {
        activityId: '1337',
        activityType:
            chrome.activityLogPrivate.ExtensionActivityType.WEB_REQUEST,
        apiCall: 'webRequest.onBeforeSendHeaders',
        args: 'null',
        count: 300,
        extensionId: EXTENSION_ID,
        other: {
          webRequest:
              `{"modified_request_headers":true, "added_request_headers":"a"}`,
        },
        pageUrl: `chrome-extension://${EXTENSION_ID}/index.html`,
        time: 1546499283237.616,
      },
      {
        activityId: '1339',
        activityType:
            chrome.activityLogPrivate.ExtensionActivityType.WEB_REQUEST,
        apiCall: 'webRequest.noWebRequestObject',
        args: 'null',
        count: 3,
        extensionId: EXTENSION_ID,
        other: {},
        pageUrl: `chrome-extension://${EXTENSION_ID}/index.html`,
        time: 1546499283237.616,
      },
    ],
  };

  /**
   * Extension activityLogHistory created before each test.
   */
  let activityLogHistory: ActivityLogHistoryElement;
  let proxyDelegate: TestService;
  let boundTestVisible: (selector: string, expectedVisible: boolean) => void;

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

    activityLogHistory = document.createElement('activity-log-history');
    boundTestVisible = testVisible.bind(null, activityLogHistory);

    activityLogHistory.extensionId = EXTENSION_ID;
    activityLogHistory.delegate = proxyDelegate;
    document.body.appendChild(activityLogHistory);

    return proxyDelegate.whenCalled('getExtensionActivityLog');
  }

  // Initialize an extension activity log before each test.
  setup(function() {
    proxyDelegate = new TestService();
  });

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

  function getHistoryItems() {
    return activityLogHistory.shadowRoot!.querySelectorAll(
        'activity-log-history-item');
  }

  // We know an item is expanded if its page-url-list is not hidden.
  function getExpandedItems() {
    return Array.from(getHistoryItems()).filter(item => {
      return item.shadowRoot!.querySelector('#page-url-list:not([hidden])');
    });
  }

  test('activities are present for extension', async function() {
    proxyDelegate.testActivities = testActivities;
    await setupActivityLogHistory();

    flush();

    boundTestVisible('#no-activities', false);
    boundTestVisible('#loading-activities', false);
    boundTestVisible('#activity-list', true);
    boundTestVisible('.activity-table-headings', true);

    const activityLogItems = getHistoryItems();
    assertEquals(activityLogItems.length, 3);

    // Test the order of the activity log items here. This test is in this
    // file because the logic to group activity log items by their API call
    // is in activity_log_history.js.
    assertEquals(
        activityLogItems[0]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-key')!.innerText!,
        'i18n.getUILanguage');
    assertEquals(
        activityLogItems[0]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-count')!.innerText!,
        '40');

    assertEquals(
        activityLogItems[1]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-key')!.innerText!,
        'Storage.getItem');
    assertEquals(
        activityLogItems[1]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-count')!.innerText!,
        '35');

    assertEquals(
        activityLogItems[2]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-key')!.innerText!,
        'Storage.setItem');
    assertEquals(
        activityLogItems[2]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-count')!.innerText!,
        '10');
  });

  test('activities shown match search query', async function() {
    proxyDelegate.testActivities = testActivities;
    await setupActivityLogHistory();

    const search =
        activityLogHistory.shadowRoot!.querySelector('cr-search-field');
    assertTrue(!!search);

    // Partial, case insensitive search for i18n.getUILanguage. Whitespace is
    // also appended to the search term to test trimming.
    search!.setValue('getuilanguage   ');

    await proxyDelegate.whenCalled('getFilteredExtensionActivityLog');

    flush();
    const activityLogItems = getHistoryItems();
    // Since we searched for an API call, we expect only one match as
    // activity log entries are grouped by their API call.
    assertEquals(activityLogItems.length, 1);
    assertEquals(
        activityLogItems[0]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-key')!.innerText!,
        'i18n.getUILanguage');

    // Change search query so no results match.
    proxyDelegate.resetResolver('getFilteredExtensionActivityLog');
    search!.setValue('query that does not match any activities');

    await proxyDelegate.whenCalled('getFilteredExtensionActivityLog');

    flush();

    boundTestVisible('#no-activities', true);
    boundTestVisible('#loading-activities', false);
    boundTestVisible('#activity-list', false);
    assertEquals(0, getHistoryItems().length);

    proxyDelegate.resetResolver('getExtensionActivityLog');

    // Finally, we clear the search query via the #clearSearch button.
    // We should see all the activities displayed.
    search!.shadowRoot!.querySelector<HTMLElement>('#clearSearch')!.click();

    await proxyDelegate.whenCalled('getExtensionActivityLog');

    flush();
    assertEquals(3, getHistoryItems().length);
  });

  test('script names shown for content script activities', async function() {
    proxyDelegate.testActivities = testContentScriptActivities;
    await setupActivityLogHistory();

    flush();
    const activityLogItems = getHistoryItems();

    // One activity should be shown for each content script name.
    assertEquals(activityLogItems.length, 2);

    assertEquals(
        activityLogItems[0]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-key')!.innerText!,
        'script1.js');
    assertEquals(
        activityLogItems[1]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-key')!.innerText!,
        'script2.js');
  });

  test(
      'other.webRequest fields shown for web request activities',
      async function() {
        proxyDelegate.testActivities = testWebRequestActivities;
        await setupActivityLogHistory();

        flush();
        const activityLogItems = getHistoryItems();

        // First activity should be split into two groups as it has two actions
        // recorded in the other.webRequest object. We display the names of
        // these actions along with the API call. Second activity should fall
        // back to using just the API call as the key. Hence we end up with
        // three activity log items.
        const expectedItemKeys = [
          'webRequest.onBeforeSendHeaders (added_request_headers)',
          'webRequest.onBeforeSendHeaders (modified_request_headers)',
          'webRequest.noWebRequestObject',
        ];
        const expectedNumItems = expectedItemKeys.length;

        assertEquals(activityLogItems.length, expectedNumItems);

        for (let i = 0; i < expectedNumItems; ++i) {
          assertEquals(
              activityLogItems[i]!.shadowRoot!
                  .querySelector<HTMLElement>('#activity-key')!.innerText!,
              expectedItemKeys[i]);
        }
      });

  test('expand/collapse all', async function() {
    proxyDelegate.testActivities = testActivities;
    await setupActivityLogHistory();

    flush();

    const expandableItems = Array.from(getHistoryItems())
                                .filter(
                                    item => item.shadowRoot!.querySelector(
                                        'cr-expand-button:not([hidden])'));
    assertEquals(2, expandableItems.length);

    // All items should be collapsed by default.
    assertEquals(0, getExpandedItems().length);

    // Click the dropdown toggle, then expand all.
    activityLogHistory.shadowRoot!.querySelector<HTMLElement>(
                                      '#more-actions')!.click();
    activityLogHistory.shadowRoot!
        .querySelector<HTMLElement>('#expand-all-button')!.click();

    flush();
    assertEquals(2, getExpandedItems().length);

    // Collapse all items.
    activityLogHistory.shadowRoot!.querySelector<HTMLElement>(
                                      '#more-actions')!.click();
    activityLogHistory.shadowRoot!
        .querySelector<HTMLElement>('#collapse-all-button')!.click();

    flush();
    assertEquals(0, getExpandedItems().length);
  });

  test('export activities', async function() {
    // |testExportActivities| stringified and sorted by timestamp.
    const expectedRawActivityData =
        '[{"activityId":"309","activityType":"dom_access","apiCall":"Storage.' +
        'getItem","args":"null","count":35,"extensionId":"aaaaaaaaaaaaaaaaaaa' +
        'aaaaaaaaaaaaa","other":{"domVerb":"method"},"pageTitle":"Test Extens' +
        'ion","pageUrl":"chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/' +
        'index.html","time":1541203131994.837},{"activityId":"299","activityT' +
        'ype":"api_call","apiCall":"i18n.getUILanguage","args":"null","count"' +
        ':10,"extensionId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","time":15412031' +
        '32002.664}]';

    proxyDelegate.testActivities = testExportActivities;
    await setupActivityLogHistory();
    flush();

    activityLogHistory.shadowRoot!.querySelector<HTMLElement>(
                                      '#more-actions')!.click();
    activityLogHistory.shadowRoot!.querySelector<HTMLElement>(
                                      '#export-button')!.click();

    const [actualRawActivityData, actualFileName] =
        await proxyDelegate.whenCalled('downloadActivities');

    assertEquals(expectedRawActivityData, actualRawActivityData);
    assertEquals(`exported_activity_log_${EXTENSION_ID}.json`, actualFileName);
  });

  test(
      'clicking on the delete button for an activity row deletes that row',
      async function() {
        proxyDelegate.testActivities = testActivities;
        await setupActivityLogHistory();

        flush();
        const activityLogItems = getHistoryItems();

        assertEquals(activityLogItems.length, 3);
        proxyDelegate.resetResolver('getExtensionActivityLog');
        activityLogItems[0]!.shadowRoot!
            .querySelector<HTMLElement>('#activity-delete')!.click();

        // We delete the first item so we should only have one item left. This
        // chaining reflects the API calls made from activity_log.js.
        await proxyDelegate.whenCalled('deleteActivitiesById');
        await proxyDelegate.whenCalled('getExtensionActivityLog');

        flush();
        assertEquals(2, getHistoryItems().length);
      });

  test(
      'message shown when no activities present for extension',
      async function() {
        // Spoof an API call and pretend that the extension has no activities.
        proxyDelegate.testActivities = {activities: []};
        await setupActivityLogHistory();

        flush();

        boundTestVisible('#no-activities', true);
        boundTestVisible('#loading-activities', false);
        boundTestVisible('#activity-list', false);
        assertEquals(0, getHistoryItems().length);
      });

  test('message shown when activities are being fetched', async function() {
    // Spoof an API call and pretend that the extension has no activities.
    proxyDelegate.testActivities = {activities: []};
    await setupActivityLogHistory();

    // Pretend the activity log is still loading.
    activityLogHistory.setPageStateForTest(ActivityLogPageState.LOADING);

    flush();

    boundTestVisible('#no-activities', false);
    boundTestVisible('#loading-activities', true);
    boundTestVisible('#activity-list', false);
  });

  test(
      'clicking on clear button clears the activity log history',
      async function() {
        proxyDelegate.testActivities = testActivities;
        await setupActivityLogHistory();

        flush();

        assertEquals(3, getHistoryItems().length);
        activityLogHistory.shadowRoot!
            .querySelector<HTMLElement>('.clear-activities-button')!.click();

        await proxyDelegate.whenCalled('deleteActivitiesFromExtension');

        flush();
        boundTestVisible('#no-activities', true);
        boundTestVisible('.activity-table-headings', false);
        assertEquals(0, getHistoryItems().length);
      });
});