chromium/chrome/test/data/webui/extensions/error_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.

/** @fileoverview Suite of tests for extensions-detail-view. */
import 'chrome://extensions/extensions.js';

import type {ErrorPageDelegate, ExtensionsErrorPageElement} from 'chrome://extensions/extensions.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {isChildVisible} from 'chrome://webui-test/test_util.js';

import {createExtensionInfo, MockItemDelegate} from './test_util.js';

// The delegate in the error page is an intersection type of
// ItemDelegate&ErrorPageDelegate and MockItemDelegate extends ClickMock.
class MockErrorPageDelegate extends MockItemDelegate implements
    ErrorPageDelegate {
  requestFileSourceArgs: chrome.developerPrivate.RequestFileSourceProperties|
      undefined;
  requestFileSourceResolver:
      PromiseResolver<chrome.developerPrivate.RequestFileSourceResponse> =
          new PromiseResolver();

  deleteErrors(
      _extensionId: string, _errorIds?: number[],
      _type?: chrome.developerPrivate.ErrorType) {}

  requestFileSource(args: chrome.developerPrivate.RequestFileSourceProperties) {
    this.requestFileSourceArgs = args;
    this.requestFileSourceResolver = new PromiseResolver();
    return this.requestFileSourceResolver.promise;
  }
}

suite('ExtensionErrorPageTest', function() {
  let extensionData: chrome.developerPrivate.ExtensionInfo;

  let errorPage: ExtensionsErrorPageElement;

  let mockDelegate: MockErrorPageDelegate;

  const extensionId: string = 'a'.repeat(32);

  // Common data for runtime errors.
  const runtimeErrorBase = {
    occurrences: 1,
    type: chrome.developerPrivate.ErrorType.RUNTIME,
    extensionId: extensionId,
    fromIncognito: false,
  };

  // Common data for manifest errors.
  const manifestErrorBase = {
    type: chrome.developerPrivate.ErrorType.MANIFEST,
    extensionId: extensionId,
    fromIncognito: false,
  };

  // Initialize an extension item before each test.
  setup(function() {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    const runtimeError = Object.assign(
        {
          contextUrl: 'Unknown',
          source: 'chrome-extension://' + extensionId + '/source.html',
          message: 'message',
          renderProcessId: 0,
          renderViewId: 0,
          canInspect: false,
          id: 1,
          stackTrace: [],
          severity: chrome.developerPrivate.ErrorLevel.ERROR,
        },
        runtimeErrorBase);
    extensionData = createExtensionInfo({
      runtimeErrors: [runtimeError],
      manifestErrors: [],
    });
    errorPage = document.createElement('extensions-error-page');
    mockDelegate = new MockErrorPageDelegate();
    errorPage.delegate = mockDelegate;
    errorPage.data = extensionData;
    document.body.appendChild(errorPage);

    // The toast manager is needed for reloading, see item_mixin.ts.
    const toastManager = document.createElement('cr-toast-manager');
    document.body.appendChild(toastManager);
  });

  test('Layout', function() {
    flush();

    const testIsVisible = isChildVisible.bind(null, errorPage);
    assertTrue(testIsVisible('#closeButton'));
    assertTrue(testIsVisible('#heading'));
    assertTrue(testIsVisible('#errorsList'));

    let errorElements = errorPage.shadowRoot!.querySelectorAll('.error-item');
    assertEquals(1, errorElements.length);
    let error = errorElements[0]!;
    assertEquals(
        'message',
        error.querySelector<HTMLElement>(
                 '.error-message')!.textContent!.trim());
    assertTrue(error.querySelector('cr-icon')!.icon === 'cr:error');

    const manifestError = Object.assign(
        {
          source: 'manifest.json',
          message: 'invalid key',
          id: 2,
          manifestKey: 'permissions',
        },
        manifestErrorBase);
    errorPage.set('data.manifestErrors', [manifestError]);
    flush();
    errorElements = errorPage.shadowRoot!.querySelectorAll('.error-item');
    assertEquals(2, errorElements.length);
    error = errorElements[0]!;
    assertEquals(
        'invalid key',
        error.querySelector<HTMLElement>(
                 '.error-message')!.textContent!.trim());
    assertTrue(error.querySelector('cr-icon')!.icon === 'cr:warning');

    mockDelegate.testClickingCalls(
        error.querySelector<HTMLElement>('.icon-delete-gray')!, 'deleteErrors',
        [extensionId, [manifestError.id]]);
  });

  test(
      'CodeSection', function(done) {
        flush();

        assertTrue(!!mockDelegate.requestFileSourceArgs);
        const args = mockDelegate.requestFileSourceArgs;
        assertEquals(extensionId, args.extensionId);
        assertEquals('source.html', args.pathSuffix);
        assertEquals('message', args.message);

        assertTrue(!!mockDelegate.requestFileSourceResolver);
        const code = {
          beforeHighlight: 'foo',
          highlight: 'bar',
          afterHighlight: 'baz',
          message: 'quu',
          title: '',
        };
        mockDelegate.requestFileSourceResolver.resolve(code);
        mockDelegate.requestFileSourceResolver.promise.then(function() {
          flush();
          assertEquals(
              code,
              errorPage.shadowRoot!.querySelector(
                                       'extensions-code-section')!.code);
          done();
        });
      });

  test('ErrorSelection', function() {
    const nextRuntimeError = Object.assign(
        {
          source: 'chrome-extension://' + extensionId + '/other_source.html',
          message: 'Other error',
          id: 2,
          severity: chrome.developerPrivate.ErrorLevel.ERROR,
          renderProcessId: 111,
          renderViewId: 222,
          canInspect: true,
          contextUrl: 'http://test.com',
          stackTrace: [{url: 'url', lineNumber: 123, columnNumber: 321}],
        },
        runtimeErrorBase);
    // Add a new runtime error to the end.
    errorPage.push('data.runtimeErrors', nextRuntimeError);
    flush();

    const errorElements = errorPage.shadowRoot!.querySelectorAll<HTMLElement>(
        '.error-item .start');
    const crCollapses = errorPage.shadowRoot!.querySelectorAll('cr-collapse');
    assertEquals(2, errorElements.length);
    assertEquals(2, crCollapses.length);

    // The first error should be focused by default, and we should have
    // requested the source for it.
    assertEquals(extensionData.runtimeErrors[0], errorPage.getSelectedError());
    assertTrue(!!mockDelegate.requestFileSourceArgs);
    let args = mockDelegate.requestFileSourceArgs;
    assertEquals('source.html', args.pathSuffix);
    assertTrue(crCollapses[0]!.opened);
    assertFalse(crCollapses[1]!.opened);

    mockDelegate.requestFileSourceResolver = new PromiseResolver();
    mockDelegate.requestFileSourceArgs = undefined;

    // Tap the second error. It should now be selected and we should request
    // the source for it.
    errorElements[1]!.click();
    assertEquals(nextRuntimeError, errorPage.getSelectedError());
    assertTrue(!!mockDelegate.requestFileSourceArgs);
    args = mockDelegate.requestFileSourceArgs;
    assertEquals('other_source.html', args.pathSuffix);
    assertTrue(crCollapses[1]!.opened);
    assertFalse(crCollapses[0]!.opened);

    assertEquals(
        'Unknown',
        crCollapses[0]!.querySelector<HTMLElement>(
                           '.context-url')!.textContent!.trim());
    assertEquals(
        nextRuntimeError.contextUrl,
        crCollapses[1]!.querySelector<HTMLElement>(
                           '.context-url')!.textContent!.trim());
  });

  // Tests that the element can still be shown with an invalid URL. Regression
  // test for crbug.com/1257170, as without the fix, this test would simply
  // crash when the page tries and fails to create a URL object.
  test('InvalidUrl', function() {
    const newRuntimeError = Object.assign(
        {
          severity: chrome.developerPrivate.ErrorLevel.ERROR,
          source: 'invalid_url',
        },
        runtimeErrorBase);
    // Replace the runtime error URL with something malformed, and check that
    // the error is still displayed and opened.
    errorPage.set('data.runtimeErrors', [newRuntimeError]);
    flush();

    assertEquals(extensionData.runtimeErrors[0], errorPage.getSelectedError());
  });

  // Test that the reload button is only shown for unpacked extensions in dev
  // mode, and that it can be clicked.
  test('ReloadItem', async function() {
    flush();

    const isVisible = isChildVisible.bind(null, errorPage);
    assertFalse(isVisible('#dev-reload-button'));

    errorPage.inDevMode = true;
    errorPage.set('data.location', chrome.developerPrivate.Location.UNPACKED);

    flush();
    assertTrue(isVisible('#dev-reload-button'));

    await mockDelegate.testClickingCalls(
        errorPage.shadowRoot!.querySelector('#dev-reload-button')!,
        'reloadItem', [errorPage.data.id], Promise.resolve());

    // Disable the extension. The button should now be hidden.
    errorPage.set(
        'data.state', chrome.developerPrivate.ExtensionState.DISABLED);

    flush();
    assertFalse(isVisible('#dev-reload-button'));
  });
});