@socketsupply/socket
Version:
A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.
265 lines (236 loc) • 7.84 kB
JavaScript
// @ts-check
/**
* @module test.dom-helpers
*
* Provides a test runner for Socket Runtime.
*
*
* Example usage:
* ```js
* import { test } from 'socket:test/dom-helpers.js'
* ```
*
*/
// Based on @socketsupply/test-dom and vhs-tape.
const SECOND = 1000
const defaultTimeout = 5 * SECOND
/**
* Converts querySelector string to an HTMLElement or validates an existing HTMLElement.
*
* @export
* @param {string|Element} selector - A CSS selector string, or an instance of HTMLElement, or Element.
* @returns {Element} The HTMLElement, Element, or Window that corresponds to the selector.
* @throws {Error} Throws an error if the `selector` is not a string that resolves to an HTMLElement or not an instance of HTMLElement, Element, or Window.
*
*/
export function toElement (selector) {
if (globalThis.document) {
if (typeof selector === 'string') {
selector = globalThis.document.querySelector(selector)
}
if (!(
selector instanceof globalThis.HTMLElement ||
selector instanceof globalThis.Element
)) {
throw new Error('stringOrElement needs to be an instance of HTMLElement or a querySelector that resolves to a HTMLElement')
}
return selector
}
}
/**
* Waits for an element to appear in the DOM and resolves the promise when it does.
*
* @export
* @param {Object} args - Configuration arguments.
* @param {string} [args.selector] - The CSS selector to look for.
* @param {boolean} [args.visible=true] - Whether the element should be visible.
* @param {number} [args.timeout=defaultTimeout] - Time in milliseconds to wait before rejecting the promise.
* @param {() => HTMLElement | Element | null | undefined} [lambda] - An optional function that returns the element. Used if the `selector` is not provided.
* @returns {Promise<Element|HTMLElement|void>} - A promise that resolves to the found element.
*
* @throws {Error} - Throws an error if neither `lambda` nor `selector` is provided.
* @throws {Error} - Throws an error if the element is not found within the timeout.
*
* @example
* ```js
* waitFor({ selector: '#my-element', visible: true, timeout: 5000 })
* .then(el => console.log('Element found:', el))
* .catch(err => console.log('Element not found:', err));
* ```
*/
export function waitFor (args, lambda) {
return new Promise((resolve, reject) => {
const {
selector,
visible = true,
timeout = defaultTimeout
} = args
if (!lambda && selector) {
lambda = () => globalThis.document?.querySelector?.(selector) ?? null
}
const interval = setInterval(() => {
if (!lambda) {
throw new Error('lambda or selector required')
}
const el = lambda()
if (el) {
if (visible && !isElementVisible(el)) return
clearTimeout(timer)
return resolve(el)
}
}, 50)
const timer = setTimeout(() => {
clearInterval(interval)
const wantsVisable = visible ? 'A visible selector' : 'A Selector'
reject(new Error(`${wantsVisable} was not found after ${timeout}ms (${selector})`))
}, timeout)
})
}
/**
* Waits for an element's text content to match a given string or regular expression.
*
* @export
* @param {Object} args - Configuration arguments.
* @param {Element} args.element - The root element from which to begin searching.
* @param {string} [args.text] - The text to search for within elements.
* @param {RegExp} [args.regex] - A regular expression to match against element text content.
* @param {boolean} [args.multipleTags=false] - Whether to look for text across multiple sibling elements.
* @param {number} [args.timeout=defaultTimeout] - Time in milliseconds to wait before rejecting the promise.
* @returns {Promise<Element|HTMLElement|void>} - A promise that resolves to the found element or null.
*
* @example
* ```js
* waitForText({ element: document.body, text: 'Hello', timeout: 5000 })
* .then(el => console.log('Element found:', el))
* .catch(err => console.log('Element not found:', err));
* ```
*/
export function waitForText (args) {
return waitFor({
timeout: args.timeout
}, () => {
const {
element,
text,
regex,
multipleTags
} = args
const elems = []
let maxLoop = 10000
const stack = [element]
// Walk the DOM tree breadth first and build up a list of
// elements with the leafs last.
while (stack.length > 0 && maxLoop-- >= 0) {
const current = stack.pop()
if (current && current.children.length > 0) {
stack.push(...current.children)
elems.push(...current.children)
}
}
// Loop over children in reverse to scan the LEAF nodes first.
let match = null
for (let i = elems.length - 1; i >= 0; i--) {
const node = elems[i]
if (!node.textContent) continue
if (regex && regex.test(node.textContent)) {
return node
}
if (text && node.textContent?.includes(text)) {
return node
}
if (text && multipleTags) {
if (text[0] !== (node.textContent)[0]) continue
// if equal, check the sibling nodes
let sibling = node.nextSibling
let i = 1
// while there is a potential match, keep checking the siblings
while (i < text.length) {
if (sibling && (sibling.textContent === text[i])) {
// is equal still, check the next sibling
sibling = sibling.nextSibling
i++
match = node.parentElement
} else {
if (i === (text.length - 1)) return node.parentElement
match = null
break
}
}
}
}
return match
})
}
/**
* @export
* @param {Object} args - Arguments
* @param {string | Event} args.event - The event to dispatch.
* @param {HTMLElement | Element | window} [args.element=window] - The element to dispatch the event on.
* @returns {void}
*
* @throws {Error} Throws an error if the `event` is not a string that can be converted to a CustomEvent or not an instance of Event.
*/
export function event (args) {
let {
event,
element = globalThis
} = args
if (typeof event === 'string') {
event = new globalThis.CustomEvent(event)
}
if (typeof event !== 'object') {
throw new Error('event should be of type Event')
}
element.dispatchEvent(event)
}
/**
* @export
* Copy pasted from https://raw.githubusercontent.com/testing-library/jest-dom/master/src/to-be-visible.js
* @param {Element | HTMLElement} element
* @param {Element | HTMLElement} [previousElement]
* @returns {boolean}
*/
export function isElementVisible (element, previousElement) {
return (
isStyleVisible(element) &&
isAttributeVisible(element, previousElement) &&
(!element.parentElement || isElementVisible(element.parentElement, element))
)
}
/**
* @param {Element | HTMLElement} element
* @returns {boolean}
* @ignore
*/
function isStyleVisible (element) {
const ownerDocument = element.ownerDocument
if (!ownerDocument || !ownerDocument.defaultView) {
throw new Error('element has no ownerDocument')
}
const {
display,
visibility,
opacity
} = ownerDocument.defaultView.getComputedStyle(element)
return (
display !== 'none' &&
visibility !== 'hidden' &&
visibility !== 'collapse' &&
opacity !== '0' &&
Number(opacity) !== 0
)
}
/**
* @param {Element | HTMLElement} element
* @param {Element | HTMLElement} [previousElement]
* @returns {boolean}
* @ignore
*/
function isAttributeVisible (element, previousElement) {
return (
!element.hasAttribute('hidden') &&
(element.nodeName === 'DETAILS' && previousElement?.nodeName !== 'SUMMARY'
? element.hasAttribute('open')
: true)
)
}