Files
react-native-devtools-frontend/front_end/testing/DOMHelpers.ts
T
Benedikt Meurer 918a821c50 [ui] Make Toolbar into a custom element with light DOM.
This is the first step towards making it possible to use the `Toolbar`
component within lit-html templates. It turns the `UI.Toolbar.Toolbar`
class into an `HTMLElement` and removes its shadow DOM, using a light
DOM instead with global styles. This makes it possible to easily style
toolbars and their elements differently in different context, without
having to inject additional styles into the shadow DOM manually.

Bug: 388445687
Change-Id: I7a4e03a8c12978b7d9d9da79597b656409b3a9cb
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6157257
Reviewed-by: Danil Somsikov <dsv@chromium.org>
Commit-Queue: Benedikt Meurer <bmeurer@chromium.org>
Auto-Submit: Benedikt Meurer <bmeurer@chromium.org>
Commit-Queue: Danil Somsikov <dsv@chromium.org>
2025-01-09 03:18:13 -08:00

294 lines
10 KiB
TypeScript

// 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 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);
}
}
/**
* 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 resetTestDOM = () => {
const previousContainer = document.getElementById(TEST_CONTAINER_ID);
if (previousContainer) {
removeChildren(previousContainer);
previousContainer.remove();
}
const newContainer = document.createElement('div');
newContainer.id = TEST_CONTAINER_ID;
document.body.appendChild(newContainer);
};
interface Constructor<T> {
new(...args: unknown[]): T;
}
/**
* Asserts that all emenents of `nodeList` are at least of type `T`.
*/
export function assertElements<T extends Element>(
nodeList: NodeListOf<Element>, elementClass: 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: 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: 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 new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
export async function raf() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
/**
* It's useful to use innerHTML in the tests to have full confidence in the
* renderer output, but LitHtml 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) {
/**
* LitHtml 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 ? element.textContent.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;
}