chromium/chrome/test/data/pdf/test_util.ts

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

// Utilities that are used in multiple tests.

import type {Bookmark, DocumentDimensions, LayoutOptions, PdfViewerElement, ViewerToolbarElement} from 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/pdf_viewer_wrapper.js';
import {Viewport} from 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/pdf_viewer_wrapper.js';
// <if expr="enable_pdf_ink2">
import {BeforeUnloadProxyImpl} from 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/pdf_viewer_wrapper.js';
import type {BeforeUnloadProxy, PluginController} from 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/pdf_viewer_wrapper.js';
import {PluginControllerEventType} from 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/pdf_viewer_wrapper.js';
// </if>
import {assert} from 'chrome://resources/js/assert.js';
import {CrLitElement, html} from 'chrome://resources/lit/v3_0/lit.rollup.js';
// <if expr="enable_pdf_ink2">
import {TestBrowserProxy} from 'chrome://webui-test/test_browser_proxy.js';
// </if>
import {eventToPromise, isVisible, microtasksFinished} from 'chrome://webui-test/test_util.js';

export class MockElement {
  dir: string = '';
  offsetWidth: number;
  offsetHeight: number;
  sizer: MockSizer|null;
  scrollLeft: number = 0;
  scrollTop: number = 0;
  scrollCallback: (() => void)|null = null;
  resizeCallback: (() => void)|null = null;

  constructor(width: number, height: number, sizer: MockSizer|null) {
    this.offsetWidth = width;
    this.offsetHeight = height;
    this.sizer = sizer;

    if (sizer) {
      sizer.resizeCallback_ = () =>
          this.scrollTo(this.scrollLeft, this.scrollTop);
    }
  }

  get clientWidth(): number {
    return this.offsetWidth;
  }

  get clientHeight(): number {
    return this.offsetHeight;
  }

  addEventListener(e: string, f: () => void) {
    if (e === 'scroll') {
      this.scrollCallback = f;
    }
  }

  setSize(width: number, height: number) {
    this.offsetWidth = width;
    this.offsetHeight = height;
    this.resizeCallback!();
  }

  scrollTo(x: number, y: number) {
    if (this.sizer) {
      x = Math.min(x, parseInt(this.sizer.style.width, 10) - this.offsetWidth);
      y = Math.min(
          y, parseInt(this.sizer.style.height, 10) - this.offsetHeight);
    }
    this.scrollLeft = Math.max(0, x);
    this.scrollTop = Math.max(0, y);
    this.scrollCallback!();
  }
}

export class MockSizer {
  private width_: string = '0px';
  private height_: string = '0px';

  resizeCallback_: (() => void)|null = null;
  style: {
    height: string,
    width: string,
    display?: string,
  };

  constructor() {
    const sizer = this;

    this.style = {
      get height() {
        return sizer.height_;
      },

      set height(height: string) {
        sizer.height_ = height;
        if (sizer.resizeCallback_) {
          sizer.resizeCallback_();
        }
      },

      get width() {
        return sizer.width_;
      },

      set width(width: string) {
        sizer.width_ = width;
        if (sizer.resizeCallback_) {
          sizer.resizeCallback_();
        }
      },
    };
  }
}

export class MockViewportChangedCallback {
  wasCalled: boolean = false;
  callback: () => void;

  constructor() {
    this.callback = this.callback_.bind(this);
  }

  private callback_() {
    this.wasCalled = true;
  }

  reset() {
    this.wasCalled = false;
  }
}

interface PageDimensions {
  x: number;
  y: number;
  width: number;
  height: number;
}

export class MockDocumentDimensions implements DocumentDimensions {
  width: number;
  height: number;
  layoutOptions?: LayoutOptions;
  pageDimensions: PageDimensions[] = [];

  constructor(width?: number, height?: number, layoutOptions?: LayoutOptions) {
    this.width = width || 0;
    this.height = height || 0;
    this.layoutOptions = layoutOptions;
  }

  addPage(w: number, h: number) {
    let y = 0;
    if (this.pageDimensions.length !== 0) {
      y = this.pageDimensions[this.pageDimensions.length - 1]!.y +
          this.pageDimensions[this.pageDimensions.length - 1]!.height;
    }
    this.width = Math.max(this.width, w);
    this.height += h;
    this.pageDimensions.push({x: 0, y: y, width: w, height: h});
  }

  addPageForTwoUpView(x: number, y: number, w: number, h: number) {
    this.width = Math.max(this.width, 2 * w);
    this.height = Math.max(this.height, y + h);
    this.pageDimensions.push({x: x, y: y, width: w, height: h});
  }

  reset() {
    this.width = 0;
    this.height = 0;
    this.pageDimensions = [];
  }
}

export class MockPdfPluginElement extends HTMLEmbedElement {
  private messages_: any[] = [];

  get messages(): any[] {
    return this.messages_;
  }

  clearMessages() {
    this.messages_.length = 0;
  }

  findMessage(type: string): any {
    return this.messages_.find(element => element.type === type);
  }

  postMessage(message: any, _transfer: Transferable[]) {
    this.messages_.push(message);
  }
}
customElements.define(
    'mock-pdf-plugin', MockPdfPluginElement, {extends: 'embed'});

/**
 * Creates a fake element simulating the PDF plugin.
 */
export function createMockPdfPluginForTest(): MockPdfPluginElement {
  return document.createElement('embed', {is: 'mock-pdf-plugin'}) as
      MockPdfPluginElement;
}

class TestBookmarksElement extends CrLitElement {
  static get is() {
    return 'test-bookmarks';
  }

  override render() {
    return this.bookmarks.map(
        item => html`<viewer-bookmark .bookmark="${item}" depth="0">
             </viewer-bookmark>`);
  }

  static override get properties() {
    return {
      bookmarks: {type: Array},
    };
  }

  bookmarks: Bookmark[] = [];
}

declare global {
  interface HTMLElementTagNameMap {
    'test-bookmarks': TestBookmarksElement;
  }
}

customElements.define(TestBookmarksElement.is, TestBookmarksElement);

/**
 * @return An element containing a dom-repeat of bookmarks, for
 *     testing the bookmarks outside of the toolbar.
 */
export function createBookmarksForTest(): TestBookmarksElement {
  return document.createElement('test-bookmarks');
}

/**
 * Checks if the PDF title matches the expected title.
 * @param expectedTitle The expected title of the PDF.
 * @return True if the PDF title matches the expected title, false otherwise.
 */
export function checkPdfTitleIsExpectedTitle(expectedTitle: string): boolean {
  const viewer = document.body.querySelector<PdfViewerElement>('#viewer')!;
  // Tab title is updated only when document.title is called in a top-level
  // document (`main_frame` of `WebContents`). For OOPIF PDF viewer, the current
  // document is the child of a top-level document, hence document.title is not
  // set and therefore validation is unnecessary.
  if (!viewer.isPdfOopifEnabled && expectedTitle !== document.title) {
    return false;
  }

  return expectedTitle === viewer.pdfTitle;
}

/**
 * Create a viewport with basic default zoom values.
 * @param sizer The element which represents the size of the document in the
 *     viewport.
 * @param scrollbarWidth The width of scrollbars on the page
 * @param defaultZoom The default zoom level.
 * @return The viewport object with zoom values set.
 */
export function getZoomableViewport(
    scrollParent: MockElement, sizer: MockSizer, scrollbarWidth: number,
    defaultZoom: number): Viewport {
  document.body.innerHTML = '';
  const dummyContent = document.createElement('div');
  document.body.appendChild(dummyContent);

  const viewport = new Viewport(
      scrollParent as unknown as HTMLElement, sizer as unknown as HTMLElement,
      dummyContent, scrollbarWidth, defaultZoom);
  viewport.setZoomFactorRange([0.25, 0.4, 0.5, 1, 2]);

  const dummyPlugin = document.createElement('embed');
  dummyPlugin.id = 'plugin';
  dummyPlugin.src = 'data:text/plain,plugin-content';
  viewport.setContent(dummyPlugin);
  return viewport;
}

/**
 * Async spin until predicate() returns true.
 */
export function waitFor(predicate: () => boolean): Promise<void> {
  if (predicate()) {
    return Promise.resolve();
  }
  return new Promise(resolve => setTimeout(() => {
                       resolve(waitFor(predicate));
                     }, 0));
}

export function createWheelEvent(
    deltaY: number, position: {clientX: number, clientY: number},
    ctrlKey: boolean): WheelEvent {
  return new WheelEvent('wheel', {
    deltaY,
    clientX: position.clientX,
    clientY: position.clientY,
    ctrlKey,
    // Necessary for preventDefault() to work.
    cancelable: true,
  });
}

/**
 * Helper to always get a non-null child element of `parent`. The element must
 * exist.
 * @param parent The parent to get the child element from.
 * @param query  The query to get the child element.
 * @returns A non-null element that matches `query`.
 */
export function getRequiredElement<K extends keyof HTMLElementTagNameMap>(
    parent: HTMLElement, query: K): HTMLElementTagNameMap[K];
export function getRequiredElement<E extends HTMLElement = HTMLElement>(
    parent: HTMLElement, query: string): E;
export function getRequiredElement(parent: HTMLElement, query: string) {
  const element = parent.shadowRoot!.querySelector(query);
  assert(element);
  return element;
}

/**
 * Open the toolbar menu. Does nothing if the menu is already open.
 * @param toolbar The toolbar containing the menu to open.
 */
export async function openToolbarMenu(toolbar: ViewerToolbarElement) {
  const menu = toolbar.$.menu;
  if (menu.open) {
    return;
  }

  getRequiredElement(toolbar, '#more').click();
  await microtasksFinished();
  assert(menu.open);
}

/**
 * Check that the checkbox menu `button` in `toolbar` matches the `checked`
 * state.
 */
export function assertCheckboxMenuButton(
    toolbar: ViewerToolbarElement, button: HTMLElement, checked: boolean) {
  chrome.test.assertTrue(toolbar.$.menu.open);

  // Check that the check mark visibility matches `checked`.
  chrome.test.assertEq(String(checked), button.getAttribute('aria-checked'));
  chrome.test.assertEq(
      checked, isVisible(button.querySelector('.check-container cr-icon')));
}

export async function ensureFullscreen(): Promise<void> {
  const viewer = document.body.querySelector('pdf-viewer');
  assert(viewer);

  if (document.fullscreenElement !== null) {
    return;
  }

  const toolbar = viewer.shadowRoot!.querySelector('viewer-toolbar');
  assert(toolbar);
  toolbar.dispatchEvent(new CustomEvent('present-click'));
  await eventToPromise('fullscreenchange', viewer.$.scroller);
}

// Subsequent calls to requestFullScreen() fail with an "API can only be
// initiated by a user gesture" error, so we need to run with user
// gesture.
export function enterFullscreenWithUserGesture(): Promise<void> {
  return new Promise(res => {
    chrome.test.runWithUserGesture(() => {
      ensureFullscreen().then(res);
    });
  });
}

// <if expr="enable_pdf_ink2">
/**
 * Helper to simulate the PDF content sending a message to the PDF extension
 * to indicate that a new ink stroke has been drawn.
 */
export function finishInkStroke(controller: PluginController) {
  const eventTarget = controller.getEventTarget();
  const message = {type: 'finishInkStroke'};

  eventTarget.dispatchEvent(new CustomEvent(
      PluginControllerEventType.PLUGIN_MESSAGE, {detail: message}));
}

export class TestBeforeUnloadProxy extends TestBrowserProxy implements
    BeforeUnloadProxy {
  constructor() {
    super(['preventDefault']);
  }

  preventDefault() {
    this.methodCalled('preventDefault');
  }
}

export function getNewTestBeforeUnloadProxy(): TestBeforeUnloadProxy {
  const testProxy = new TestBeforeUnloadProxy();
  BeforeUnloadProxyImpl.setInstance(testProxy);
  return testProxy;
}
// </if>