mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-09-29 05:34:21 +08:00
Tests run in a subframe and `scrollIntoView` could scroll the parent frame, which could change the visible portion of the test frame. Bug: 401489541 Change-Id: I4e167448f66d5ba54414c9a9ce369e048b876921 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6382080 Commit-Queue: Philip Pfaffe <pfaffe@chromium.org> Auto-Submit: Danil Somsikov <dsv@chromium.org> Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
339 lines
11 KiB
TypeScript
339 lines
11 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
};
|
|
|
|
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 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 result = await window.assertScreenshot(`#${TEST_CONTAINER_ID}`, filename);
|
|
if (result) {
|
|
throw new Error(result);
|
|
}
|
|
}
|