| // Copyright 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * These helpers are designed to be used when testing components or other code that renders into the DOM. |
| * By using these helpers we ensure the DOM is correctly cleaned between test runs. |
| * |
| * Note that `resetTestDOM` is automatically run before each test (see `test_setup.ts`). |
| **/ |
| |
| import type * as Platform from '../core/platform/platform.js'; |
| import type * as NodeText from '../ui/components/node_text/node_text.js'; |
| import * as UI from '../ui/legacy/legacy.js'; |
| |
| const TEST_CONTAINER_ID = '__devtools-test-container-id'; |
| |
| interface RenderOptions { |
| allowMultipleChildren?: boolean; |
| } |
| |
| /** |
| * Renders a given element into the DOM. By default it will error if it finds an element already rendered but this can be controlled via the options. |
| **/ |
| export function renderElementIntoDOM<E extends Element>(element: E, renderOptions: RenderOptions = {}): E { |
| const container = document.getElementById(TEST_CONTAINER_ID); |
| |
| if (!container) { |
| throw new Error(`renderElementIntoDOM expects to find ${TEST_CONTAINER_ID}`); |
| } |
| |
| const allowMultipleChildren = Boolean(renderOptions.allowMultipleChildren); |
| |
| if (container.childNodes.length !== 0 && !allowMultipleChildren) { |
| throw new Error(`renderElementIntoDOM expects the container to be empty ${container.innerHTML}`); |
| } |
| container.appendChild(element); |
| return element; |
| } |
| |
| function removeChildren(node: Node): void { |
| while (true) { |
| const {firstChild} = node; |
| if (firstChild === null) { |
| break; |
| } |
| const widget = UI.Widget.Widget.get(firstChild); |
| if (widget) { |
| // Child is a widget, so we have to use the Widget system to remove it from the DOM. |
| widget.detach(); |
| continue; |
| } |
| // For regular children, recursively remove their children, since some of them |
| // might be widgets, and only afterwards remove the child from the current node. |
| removeChildren(firstChild); |
| node.removeChild(firstChild); |
| } |
| } |
| |
| /** |
| * Sets up the DOM for testing, |
| * If not clean logs an error and cleans itself |
| **/ |
| export const setupTestDOM = async () => { |
| const previousContainer = document.getElementById(TEST_CONTAINER_ID); |
| if (previousContainer) { |
| // This should not be reachable, unless the |
| // AfterEach hook fails before cleaning the DOM. |
| // Clean it here and report |
| console.error('Non clean test state found!'); |
| await cleanTestDOM(); |
| } |
| const newContainer = document.createElement('div'); |
| newContainer.id = TEST_CONTAINER_ID; |
| |
| document.body.appendChild(newContainer); |
| }; |
| |
| /** |
| * Completely cleans out the test DOM to ensure it's empty for the next test run. |
| * This is run automatically between tests - you should not be manually calling this yourself. |
| **/ |
| export const cleanTestDOM = async () => { |
| const previousContainer = document.getElementById(TEST_CONTAINER_ID); |
| if (previousContainer) { |
| removeChildren(previousContainer); |
| previousContainer.remove(); |
| } |
| await raf(); |
| }; |
| |
| /** |
| * Asserts that all emenents of `nodeList` are at least of type `T`. |
| */ |
| export function assertElements<T extends Element>( |
| nodeList: NodeListOf<Element>, |
| elementClass: Platform.Constructor.Constructor<T>): asserts nodeList is NodeListOf<T> { |
| nodeList.forEach(e => assert.instanceOf(e, elementClass)); |
| } |
| |
| export function getElementWithinComponent<T extends HTMLElement, V extends Element>( |
| component: T, selector: string, elementClass: Platform.Constructor.Constructor<V>) { |
| assert.isNotNull(component.shadowRoot); |
| const element = component.shadowRoot.querySelector(selector); |
| assert.instanceOf(element, elementClass); |
| return element; |
| } |
| |
| export function getElementsWithinComponent<T extends HTMLElement, V extends Element>( |
| component: T, selector: string, elementClass: Platform.Constructor.Constructor<V>) { |
| assert.isNotNull(component.shadowRoot); |
| const elements = component.shadowRoot.querySelectorAll(selector); |
| assertElements(elements, elementClass); |
| return elements; |
| } |
| |
| /* Waits for the given element to have a scrollLeft property of at least desiredScrollLeft */ |
| export function waitForScrollLeft<T extends Element>(element: T, desiredScrollLeft: number): Promise<void> { |
| let lastScrollLeft = -1; |
| let scrollLeftTimeout: number|null = null; |
| |
| const timeBetweenPolls = 50; |
| |
| return new Promise(resolve => { |
| const pollForScrollLeft = () => { |
| const newScrollLeft = element.scrollLeft; |
| // If we get the same scroll value twice in a row, and it's at least what |
| // we want, we're done! |
| if (lastScrollLeft === newScrollLeft && newScrollLeft >= desiredScrollLeft) { |
| if (scrollLeftTimeout) { |
| window.clearTimeout(scrollLeftTimeout); |
| } |
| resolve(); |
| return; |
| } |
| |
| lastScrollLeft = newScrollLeft; |
| scrollLeftTimeout = window.setTimeout(pollForScrollLeft, timeBetweenPolls); |
| }; |
| |
| window.setTimeout(pollForScrollLeft, timeBetweenPolls); |
| }); |
| } |
| |
| /** |
| * Dispatches a mouse click event. |
| */ |
| export function dispatchClickEvent<T extends Element>(element: T, options: MouseEventInit = {}) { |
| const clickEvent = new MouseEvent('click', options); |
| element.dispatchEvent(clickEvent); |
| } |
| |
| export function dispatchMouseUpEvent<T extends Element>(element: T, options: MouseEventInit = {}) { |
| const clickEvent = new MouseEvent('mouseup', options); |
| element.dispatchEvent(clickEvent); |
| } |
| |
| export function dispatchBlurEvent<T extends Element>(element: T, options: FocusEventInit = {}) { |
| const focusEvent = new FocusEvent('blur', options); |
| element.dispatchEvent(focusEvent); |
| } |
| |
| export function dispatchFocusEvent<T extends Element>(element: T, options: FocusEventInit = {}) { |
| const focusEvent = new FocusEvent('focus', options); |
| element.dispatchEvent(focusEvent); |
| } |
| |
| export function dispatchFocusOutEvent<T extends Element>(element: T, options: FocusEventInit = {}) { |
| const focusEvent = new FocusEvent('focusout', options); |
| element.dispatchEvent(focusEvent); |
| } |
| |
| /** |
| * Dispatches a keydown event. Errors if the event was not dispatched successfully. |
| */ |
| export function dispatchKeyDownEvent<T extends Element>(element: T, options: KeyboardEventInit = {}) { |
| const clickEvent = new KeyboardEvent('keydown', options); |
| const success = element.dispatchEvent(clickEvent); |
| if (!success) { |
| assert.fail('Failed to trigger keydown event successfully.'); |
| } |
| } |
| |
| export function dispatchInputEvent<T extends Element>(element: T, options: InputEventInit = {}) { |
| const inputEvent = new InputEvent('input', options); |
| element.dispatchEvent(inputEvent); |
| } |
| |
| /** |
| * Dispatches a mouse over event. |
| */ |
| export function dispatchMouseOverEvent<T extends Element>(element: T, options: MouseEventInit = {}) { |
| const moveEvent = new MouseEvent('mouseover', options); |
| element.dispatchEvent(moveEvent); |
| } |
| /** |
| * Dispatches a mouse out event. |
| */ |
| export function dispatchMouseOutEvent<T extends Element>(element: T, options: MouseEventInit = {}) { |
| const moveEvent = new MouseEvent('mouseout', options); |
| element.dispatchEvent(moveEvent); |
| } |
| |
| /** |
| * Dispatches a mouse move event. |
| */ |
| export function dispatchMouseMoveEvent<T extends Element>(element: T, options: MouseEventInit = {}) { |
| const moveEvent = new MouseEvent('mousemove', options); |
| element.dispatchEvent(moveEvent); |
| } |
| |
| /** |
| * Dispatches a mouse leave event. |
| */ |
| export function dispatchMouseLeaveEvent<T extends Element>(element: T, options: MouseEventInit = {}) { |
| const leaveEvent = new MouseEvent('mouseleave', options); |
| element.dispatchEvent(leaveEvent); |
| } |
| |
| /** |
| * Dispatches a clipboard copy event. |
| */ |
| export function dispatchCopyEvent<T extends Element>(element: T, options: ClipboardEventInit = {}) { |
| const copyEvent = new ClipboardEvent('copy', options); |
| element.dispatchEvent(copyEvent); |
| } |
| |
| /** |
| * Dispatches a clipboard paste event. |
| */ |
| export function dispatchPasteEvent<T extends Element>(element: T, options: ClipboardEventInit = {}) { |
| const pasteEvent = new ClipboardEvent('paste', options); |
| element.dispatchEvent(pasteEvent); |
| } |
| |
| /** |
| * Listens to an event of an element and returns a Promise that resolves to the |
| * specified event type. |
| */ |
| export function getEventPromise<T extends Event>(element: HTMLElement, eventName: string): Promise<T> { |
| return new Promise<T>(resolve => { |
| element.addEventListener(eventName, (event: Event) => { |
| resolve(event as T); |
| }, {once: true}); |
| }); |
| } |
| |
| export async function doubleRaf() { |
| return await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); |
| } |
| |
| export async function raf() { |
| return await new Promise(resolve => requestAnimationFrame(resolve)); |
| } |
| |
| /** |
| * It's useful to use innerHTML in the tests to have full confidence in the |
| * renderer output, but Lit uses comment nodes to split dynamic from |
| * static parts of a template, and we don't want our tests full of noise |
| * from those. |
| */ |
| export function stripLitHtmlCommentNodes(text: string) { |
| /** |
| * Lit comments take the form of: |
| * <!--?lit$1234?--> or: |
| * <!--?--> |
| * <!----> |
| * And this regex matches all of them. |
| */ |
| return text.replaceAll(/<!--(\?)?(lit\$[0-9]+\$)?-->/g, ''); |
| } |
| |
| /** |
| * Returns an array of textContents. |
| * Multiple consecutive newLine and space characters are removed. |
| */ |
| export function getCleanTextContentFromElements(el: ShadowRoot|HTMLElement, selector: string): string[] { |
| const elements = Array.from(el.querySelectorAll(selector)); |
| return elements.map(element => { |
| return element.textContent ? element.textContent.trim().replace(/[ \n]{2,}/g, ' ') : ''; |
| }); |
| } |
| |
| /** |
| * Returns the text content for the first element matching the given `selector` within the provided `el`. |
| * Will error if no element is found matching the selector. |
| */ |
| export function getCleanTextContentFromSingleElement(el: ShadowRoot|HTMLElement, selector: string): string { |
| const element = el.querySelector(selector); |
| assert.isOk(element, `Could not find element with selector ${selector}`); |
| return element.textContent ? cleanTextContent(element.textContent) : ''; |
| } |
| |
| export function cleanTextContent(input: string): string { |
| return input.trim().replace(/[ \n]{2,}/g, ' '); |
| } |
| |
| export function assertNodeTextContent(component: NodeText.NodeText.NodeText, expectedContent: string) { |
| assert.isNotNull(component.shadowRoot); |
| const content = Array.from(component.shadowRoot.querySelectorAll('span')).map(span => span.textContent).join(''); |
| assert.strictEqual(content, expectedContent); |
| } |
| |
| export function querySelectorErrorOnMissing<T extends HTMLElement = HTMLElement>( |
| parent: HTMLElement, selector: string): T { |
| const elem = parent.querySelector<T>(selector); |
| if (!elem) { |
| throw new Error(`Expected element with selector ${selector} not found.`); |
| } |
| return elem; |
| } |
| |
| /** |
| * Given a filename in the format "<folder>/<image.png>" |
| * this function asserts that a screenshot taken from the element |
| * identified by the TEST_CONTAINER_ID matches a screenshot |
| * in test/interactions/goldens/linux/<folder>/<image.png>. |
| * |
| * Currently, it only asserts screenshots match goldens on Linux. |
| * The function relies on the bindings exposed via the karma config. |
| */ |
| export async function assertScreenshot(filename: string) { |
| // To avoid a lot of empty space in the screenshot. |
| document.getElementById(TEST_CONTAINER_ID)!.style.width = 'fit-content'; |
| let frame: Window|null = window; |
| while (frame) { |
| frame.scrollTo(0, 0); |
| frame = frame.parent !== frame ? frame.parent : null; |
| } |
| await raf(); |
| // @ts-expect-error see karma config. |
| const errorMessage = await window.assertScreenshot(`#${TEST_CONTAINER_ID}`, filename); |
| if (errorMessage) { |
| throw new Error(errorMessage); |
| } |
| } |