import { h } from '@stencil/core'
import { $ } from '@wdio/globals'
import type { StencilEnvironment } from '../../../stencil/index.d.ts'
* Emulate Node.js `nextTick` function in browser.
* This is used by Stencil.js internally.
*/
process.nextTick = (cb) => setTimeout(cb, 0)
* in case the user has his tsconfig.json configured to expect "jsx" to be "react"
*/
window.React = {
createElement: h
}
import type {
ComponentRuntimeMeta,
ComponentTestingConstructor,
HostElement,
HostRef,
LazyBundlesRuntimeData,
NewSpecPageOptions,
} from '@stencil/core/internal'
import {
bootstrapLazy,
flushAll,
insertVdomAnnotations,
registerComponents,
registerModule,
renderVdom,
setSupportsShadowDom,
startAutoApplyChanges,
styles,
writeTask,
} from '@stencil/core/internal/testing/index.js'
* Creates a new spec page for unit testing
* @param opts the options to apply to the spec page that influence its configuration and operation
* @returns the created spec page
*/
export function render(opts: NewSpecPageOptions): StencilEnvironment {
if (!opts) {
throw new Error('NewSpecPageOptions required')
}
const components = opts.components || []
const stencilStage = document.querySelector('stencil-stage')
if (stencilStage) {
stencilStage.remove()
}
const container = document.createElement('stencil-stage')
document.body.appendChild(container)
if (Array.isArray(components)) {
registerComponents(components)
}
if (opts.hydrateClientSide) {
opts.includeAnnotations = true
}
if (opts.hydrateServerSide) {
opts.includeAnnotations = true
setSupportsShadowDom(false)
} else {
opts.includeAnnotations = !!opts.includeAnnotations
if (opts.supportsShadowDom === false) {
setSupportsShadowDom(false)
} else {
setSupportsShadowDom(true)
}
}
const cmpTags = new Set<string>()
const lazyBundles: LazyBundlesRuntimeData = components.map((Cstr: ComponentTestingConstructor) => {
if (Cstr.COMPILER_META == null) {
throw new Error('Invalid component class: Missing static "COMPILER_META" property.')
}
cmpTags.add(Cstr.COMPILER_META.tagName)
Cstr.isProxied = false
proxyComponentLifeCycles(Cstr)
const bundleId = `${Cstr.COMPILER_META.tagName}.${Math.round(Math.random() * 899999) + 100000}`
const stylesMeta = Cstr.COMPILER_META.styles
if (Array.isArray(stylesMeta)) {
if (stylesMeta.length > 1) {
const styles: any = {}
stylesMeta.forEach((style) => {
styles[style.modeName] = style.styleStr
})
Cstr.style = styles
} else if (stylesMeta.length === 1) {
Cstr.style = stylesMeta[0].styleStr
}
}
registerModule(bundleId, Cstr)
* Register the component as a custom element only if not already registered
*/
if (!customElements.get(Cstr.COMPILER_META.tagName)) {
customElements.define(Cstr.COMPILER_META.tagName, Cstr as any)
}
const lazyBundleRuntimeMeta = formatLazyBundleRuntimeMeta(bundleId, [Cstr.COMPILER_META])
return lazyBundleRuntimeMeta
})
const page = {
container,
styles,
flushAll,
unmount: () => container.remove()
} as const
if (typeof opts.direction === 'string') {
document.documentElement.setAttribute('dir', opts.direction)
}
if (typeof opts.language === 'string') {
document.documentElement.setAttribute('lang', opts.language)
}
bootstrapLazy(lazyBundles)
if (typeof opts.template === 'function') {
const cmpMeta: ComponentRuntimeMeta = {
$flags$: 0,
$tagName$: 'body',
}
const ref: HostRef = {
$ancestorComponent$: undefined,
$flags$: 0,
$modeName$: undefined,
$cmpMeta$: cmpMeta,
$hostElement$: container,
}
renderVdom(ref, opts.template())
} else if (typeof opts.html === 'string') {
container.innerHTML = opts.html
}
let rootComponent: any = null
Object.defineProperty(page, 'root', {
get() {
if (!rootComponent) {
rootComponent = findRootComponent(cmpTags, container)
}
if (rootComponent) {
return rootComponent
}
return container.firstElementChild
},
})
Object.defineProperty(page, '$root', {
get() {
return $((page as any).root)
}
})
Object.defineProperty(page, '$container', {
get() {
return $(container)
}
})
if (opts.hydrateServerSide) {
insertVdomAnnotations(document, [])
}
if (opts.autoApplyChanges) {
startAutoApplyChanges()
}
return page as StencilEnvironment
}
* A helper method that proxies Stencil lifecycle methods by mutating the provided component class
* @param Cstr the component class whose lifecycle methods will be proxied
*/
function proxyComponentLifeCycles(Cstr: ComponentTestingConstructor): void {
if (typeof Cstr.prototype?.__componentWillLoad === 'function') {
Cstr.prototype.componentWillLoad = Cstr.prototype.__componentWillLoad
Cstr.prototype.__componentWillLoad = null
}
if (typeof Cstr.prototype?.__componentWillUpdate === 'function') {
Cstr.prototype.componentWillUpdate = Cstr.prototype.__componentWillUpdate
Cstr.prototype.__componentWillUpdate = null
}
if (typeof Cstr.prototype?.__componentWillRender === 'function') {
Cstr.prototype.componentWillRender = Cstr.prototype.__componentWillRender
Cstr.prototype.__componentWillRender = null
}
if (typeof Cstr.prototype?.componentWillLoad === 'function') {
Cstr.prototype.__componentWillLoad = Cstr.prototype.componentWillLoad
Cstr.prototype.componentWillLoad = function () {
const result = this.__componentWillLoad()
if (result && typeof result.then === 'function') {
writeTask(() => result)
} else {
writeTask(() => Promise.resolve())
}
return result
}
}
if (typeof Cstr.prototype?.componentWillUpdate === 'function') {
Cstr.prototype.__componentWillUpdate = Cstr.prototype.componentWillUpdate
Cstr.prototype.componentWillUpdate = function () {
const result = this.__componentWillUpdate()
if (result && typeof result.then === 'function') {
writeTask(() => result)
} else {
writeTask(() => Promise.resolve())
}
return result
}
}
if (typeof Cstr.prototype?.componentWillRender === 'function') {
Cstr.prototype.__componentWillRender = Cstr.prototype.componentWillRender
Cstr.prototype.componentWillRender = function () {
const result = this.__componentWillRender()
if (result && typeof result.then === 'function') {
writeTask(() => result)
} else {
writeTask(() => Promise.resolve())
}
return result
}
}
}
* Return the first Element whose {@link Element#nodeName} property matches a tag found in the provided `cmpTags`
* argument.
*
* If the `nodeName` property on the element matches any of the names found in the provided `cmpTags` argument, that
* element is returned. If no match is found on the current element, the children will be inspected in a depth-first
* search manner. This process continues until either:
* - an element is found (and execution ends)
* - no element is found after an exhaustive search
*
* @param cmpTags component tag names to use in the match criteria
* @param node the node whose children are to be inspected
* @returns An element whose name matches one of the strings in the provided `cmpTags`. If no match is found, `null` is
* returned
*/
function findRootComponent(cmpTags: Set<string>, node: Element): Element | null {
if (node) {
const children = node.children
const childrenLength = children.length
for (let i = 0; i < childrenLength; i++) {
const elm = children[i]
if (cmpTags.has(elm.nodeName.toLowerCase())) {
return elm
}
}
for (let i = 0; i < childrenLength; i++) {
const r = findRootComponent(cmpTags, children[i])
if (r) {
return r
}
}
}
return null
}
* Helper method to wait until all Stencil components are rendered
*/
export function waitForChanges (documentElement = document.documentElement) {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
const promiseChain: Promise<void>[] = []
const waitComponentOnReady = (elm: Element | ShadowRoot, promises: Promise<void>[]) => {
if ('shadowRoot' in elm && elm.shadowRoot instanceof ShadowRoot) {
waitComponentOnReady(elm.shadowRoot, promises)
}
const children = elm.children
const len = children.length
for (let i = 0; i < len; i++) {
const childElm = children[i]
const childStencilElm = childElm as HostElement
if (childElm.tagName.includes('-') && typeof childStencilElm.componentOnReady === 'function') {
promises.push(childStencilElm.componentOnReady().then(() => {}))
}
waitComponentOnReady(childElm, promises)
}
}
waitComponentOnReady(documentElement, promiseChain)
Promise.all(promiseChain)
.then(() => resolve())
.catch(() => resolve())
})
})
}
* A set of flags used for bitwise calculations against {@link ComponentRuntimeMeta#$flags$}.
*
* These flags should only be used in conjunction with {@link ComponentRuntimeMeta#$flags$}.
* They should _not_ be used for calculations against other fields/numbers
*/
export const enum CMP_FLAGS {
* Used to determine if a component is using the shadow DOM.
* e.g. `shadow: true | {}` is set on the `@Component()` decorator
*/
shadowDomEncapsulation = 1 << 0,
* Used to determine if a component is using scoped stylesheets
* e.g. `scoped: true` is set on the `@Component()` decorator
*/
scopedCssEncapsulation = 1 << 1,
* Used to determine if a component does not use the shadow DOM _and_ has `<slot/>` tags in its markup.
*/
hasSlotRelocation = 1 << 2,
* Determines if a shim for the shadow DOM is necessary.
*
* The shim should only be needed if a component requires {@link shadowDomEncapsulation} and if any output
* target-specific criteria are met. Refer to this flag's usage to determine each output target's criteria.
*/
needsShadowDomShim = 1 << 3,
* Determines if `delegatesFocus` is enabled for a component that uses the shadow DOM.
* e.g. `shadow: { delegatesFocus: true }` is set on the `@Component()` decorator
*/
shadowDelegatesFocus = 1 << 4,
* Determines if `mode` is set on the `@Component()` decorator
*/
hasMode = 1 << 5,
* Determines if styles must be scoped due to either:
* 1. A component is using scoped stylesheets ({@link scopedCssEncapsulation})
* 2. A component is using the shadow DOM _and_ the output target's rules for requiring a shadow DOM shim have been
* met ({@link needsShadowDomShim})
*/
needsScopedEncapsulation = scopedCssEncapsulation | needsShadowDomShim,
}
const formatLazyBundleRuntimeMeta = (
bundleId: any,
cmps: any[],
): any => {
return [bundleId, cmps.map((cmp) => formatComponentRuntimeMeta(cmp, true))]
}
* Transform metadata about a component from the compiler to a compact form for
* use at runtime.
*
* @param compilerMeta component metadata gathered during compilation
* @param includeMethods include methods in the component's members or not
* @returns a compact format for component metadata, intended for runtime use
*/
export const formatComponentRuntimeMeta = (
compilerMeta: any,
includeMethods: boolean,
): any => {
let flags = 0
if (compilerMeta.encapsulation === 'shadow') {
flags |= CMP_FLAGS.shadowDomEncapsulation
if (compilerMeta.shadowDelegatesFocus) {
flags |= CMP_FLAGS.shadowDelegatesFocus
}
} else if (compilerMeta.encapsulation === 'scoped') {
flags |= CMP_FLAGS.scopedCssEncapsulation
}
if (compilerMeta.encapsulation !== 'shadow' && compilerMeta.htmlTagNames.includes('slot')) {
flags |= CMP_FLAGS.hasSlotRelocation
}
if (compilerMeta.hasMode) {
flags |= CMP_FLAGS.hasMode
}
const members = formatComponentRuntimeMembers(compilerMeta, includeMethods)
const hostListeners = formatHostListeners(compilerMeta)
return trimFalsy([
flags,
compilerMeta.tagName,
Object.keys(members).length > 0 ? members : undefined,
hostListeners.length > 0 ? hostListeners : undefined,
])
}
const formatComponentRuntimeMembers = (
compilerMeta: any,
includeMethods = true,
): any => {
return {
...formatPropertiesRuntimeMember(compilerMeta.properties),
...formatStatesRuntimeMember(compilerMeta.states),
...(includeMethods ? formatMethodsRuntimeMember(compilerMeta.methods) : {}),
}
}
const formatPropertiesRuntimeMember = (properties: any[]) => {
const runtimeMembers: any = {}
properties.forEach((member) => {
runtimeMembers[member.name] = trimFalsy([
* [0] member type
*/
formatFlags(member),
formatAttrName(member),
])
})
return runtimeMembers
}
const formatFlags = (compilerProperty: any) => {
let type = formatPropType(compilerProperty.type)
if (compilerProperty.mutable) {
type |= MEMBER_FLAGS.Mutable
}
if (compilerProperty.reflect) {
type |= MEMBER_FLAGS.ReflectAttr
}
return type
}
const formatAttrName = (compilerProperty: any) => {
if (typeof compilerProperty.attribute === 'string') {
if (compilerProperty.name === compilerProperty.attribute) {
return undefined
}
return compilerProperty.attribute
}
return undefined
}
const enum MEMBER_FLAGS {
String = 1 << 0,
Number = 1 << 1,
Boolean = 1 << 2,
Any = 1 << 3,
Unknown = 1 << 4,
State = 1 << 5,
Method = 1 << 6,
Event = 1 << 7,
Element = 1 << 8,
ReflectAttr = 1 << 9,
Mutable = 1 << 10,
Prop = String | Number | Boolean | Any | Unknown,
HasAttribute = String | Number | Boolean | Any,
PropLike = Prop | State,
}
const formatPropType = (type: any) => {
if (type === 'string') {
return MEMBER_FLAGS.String
}
if (type === 'number') {
return MEMBER_FLAGS.Number
}
if (type === 'boolean') {
return MEMBER_FLAGS.Boolean
}
if (type === 'any') {
return MEMBER_FLAGS.Any
}
return MEMBER_FLAGS.Unknown
}
const formatStatesRuntimeMember = (states: any[]) => {
const runtimeMembers: any = {}
states.forEach((member) => {
runtimeMembers[member.name] = [
* [0] member flags
*/
MEMBER_FLAGS.State,
]
})
return runtimeMembers
}
const formatMethodsRuntimeMember = (methods: any[]) => {
const runtimeMembers: any = {}
methods.forEach((member) => {
runtimeMembers[member.name] = [
* [0] member flags
*/
MEMBER_FLAGS.Method,
]
})
return runtimeMembers
}
const formatHostListeners = (compilerMeta: any) => {
return compilerMeta.listeners.map((compilerListener: any) => {
const hostListener: any = [
computeListenerFlags(compilerListener),
compilerListener.name,
compilerListener.method,
]
return hostListener
})
}
const enum LISTENER_FLAGS {
Passive = 1 << 0,
Capture = 1 << 1,
TargetDocument = 1 << 2,
TargetWindow = 1 << 3,
TargetBody = 1 << 4,
* @deprecated Prevented from new apps, but left in for older collections
*/
TargetParent = 1 << 5,
}
const computeListenerFlags = (listener: any) => {
let flags = 0
if (listener.capture) {
flags |= LISTENER_FLAGS.Capture
}
if (listener.passive) {
flags |= LISTENER_FLAGS.Passive
}
switch (listener.target) {
case 'document':
flags |= LISTENER_FLAGS.TargetDocument
break
case 'window':
flags |= LISTENER_FLAGS.TargetWindow
break
case 'body':
flags |= LISTENER_FLAGS.TargetBody
break
case 'parent' as any:
flags |= LISTENER_FLAGS.TargetParent
break
}
return flags
}
const trimFalsy = (data: any): any => {
const arr = data as any[]
for (let i = arr.length - 1; i >= 0; i--) {
if (arr[i]) {
break
}
arr.pop()
}
return arr
}