2020-01-07 03:22:35 +01:00
/ * *
* Copyright ( c ) Microsoft Corporation .
*
* Licensed under the Apache License , Version 2.0 ( the "License" ) ;
* you may not use this file except in compliance with the License .
* You may obtain a copy of the License at
*
* http : //www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing , software
* distributed under the License is distributed on an "AS IS" BASIS ,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND , either express or implied .
* See the License for the specific language governing permissions and
* limitations under the License .
* /
2019-11-21 23:43:30 +01:00
2022-04-06 23:57:14 +02:00
import type { SelectorEngine , SelectorRoot } from './selectorEngine' ;
2020-05-16 00:21:49 +02:00
import { XPathEngine } from './xpathSelectorEngine' ;
2021-08-08 01:51:39 +02:00
import { ReactEngine } from './reactSelectorEngine' ;
2021-08-09 10:34:52 +02:00
import { VueEngine } from './vueSelectorEngine' ;
2022-11-12 00:58:36 +01:00
import { createRoleEngine } from './roleSelectorEngine' ;
2023-03-07 03:49:14 +01:00
import { parseAttributeSelector } from '../../utils/isomorphic/selectorParser' ;
import type { NestedSelectorBody , ParsedSelector , ParsedSelectorPart } from '../../utils/isomorphic/selectorParser' ;
2023-06-20 00:22:26 +02:00
import { visitAllSelectorParts , parseSelector , stringifySelector } from '../../utils/isomorphic/selectorParser' ;
2023-06-22 17:34:08 +02:00
import { type TextMatcher , elementMatchesText , elementText , type ElementText , getElementLabels } from './selectorUtils' ;
2023-03-22 23:28:59 +01:00
import { SelectorEvaluatorImpl , sortInDOMOrder } from './selectorEvaluator' ;
2024-01-23 20:29:40 +01:00
import { enclosingShadowRootOrDocument , isElementVisible , isInsideScope , parentElementOrShadowHost , setBrowserName } from './domUtils' ;
2023-03-07 03:49:14 +01:00
import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser' ;
2023-06-14 06:25:39 +02:00
import { generateSelector , type GenerateSelectorOptions } from './selectorGenerator' ;
2022-09-21 03:41:51 +02:00
import type * as channels from '@protocol/channels' ;
2022-01-12 16:37:48 +01:00
import { Highlight } from './highlight' ;
2024-12-27 10:54:16 +01:00
import { getChecked , getAriaDisabled , getAriaRole , getElementAccessibleName , getElementAccessibleDescription , getReadonly , getElementAccessibleErrorMessage } from './roleUtils' ;
2022-05-03 11:33:33 +02:00
import { kLayoutSelectorNames , type LayoutSelectorName , layoutSelectorScore } from './layoutSelectorUtils' ;
2023-03-07 03:49:14 +01:00
import { asLocator } from '../../utils/isomorphic/locatorGenerators' ;
import type { Language } from '../../utils/isomorphic/locatorGenerators' ;
2024-10-14 23:07:19 +02:00
import { cacheNormalizedWhitespaces , normalizeWhiteSpace , trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils' ;
2024-11-15 22:48:43 +01:00
import { matchesAriaTree , getAllByAria , generateAriaTree , renderAriaTree } from './ariaSnapshot' ;
import type { AriaNode , AriaSnapshot } from './ariaSnapshot' ;
2024-11-08 16:43:01 +01:00
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot' ;
import { parseYamlTemplate } from '@isomorphic/ariaSnapshot' ;
2019-12-17 05:49:18 +01:00
2021-09-24 20:06:30 +02:00
export type FrameExpectParams = Omit < channels.FrameExpectParams , ' expectedValue ' > & { expectedValue? : any } ;
2025-01-08 00:31:18 +01:00
export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'mixed' | 'stable' ;
export type ElementStateWithoutStable = Exclude < ElementState , ' stable ' > ;
export type ElementStateQueryResult = { matches : boolean , received? : string | 'error:notconnected' } ;
2021-02-10 21:36:26 +01:00
2021-11-06 01:31:28 +01:00
export type HitTargetInterceptionResult = {
stop : ( ) = > 'done' | { hitTargetDescription : string } ;
} ;
2023-11-08 18:50:25 +01:00
interface WebKitLegacyDeviceOrientationEvent extends DeviceOrientationEvent {
readonly initDeviceOrientationEvent : ( type : string , bubbles : boolean , cancelable : boolean , alpha : number , beta : number , gamma : number , absolute : boolean ) = > void ;
}
2023-11-13 17:58:46 +01:00
interface WebKitLegacyDeviceMotionEvent extends DeviceMotionEvent {
readonly initDeviceMotionEvent : ( type : string , bubbles : boolean , cancelable : boolean , acceleration : DeviceMotionEventAcceleration , accelerationIncludingGravity : DeviceMotionEventAcceleration , rotationRate : DeviceMotionEventRotationRate , interval : number ) = > void ;
}
2020-08-25 00:30:45 +02:00
export class InjectedScript {
2022-12-05 23:08:54 +01:00
private _engines : Map < string , SelectorEngine > ;
2021-02-09 06:53:17 +01:00
_evaluator : SelectorEvaluatorImpl ;
2021-02-10 21:36:26 +01:00
private _stableRafCount : number ;
2021-09-25 05:51:09 +02:00
private _browserName : string ;
2021-10-31 01:06:52 +02:00
onGlobalListenersRemoved = new Set < ( ) = > void > ( ) ;
2021-11-06 01:31:28 +01:00
private _hitTargetInterceptor : undefined | ( ( event : MouseEvent | PointerEvent | TouchEvent ) = > void ) ;
2022-01-12 16:37:48 +01:00
private _highlight : Highlight | undefined ;
2022-03-01 22:56:21 +01:00
readonly isUnderTest : boolean ;
2022-10-18 22:09:54 +02:00
private _sdkLanguage : Language ;
2022-11-08 21:04:43 +01:00
private _testIdAttributeNameForStrictErrorAndConsoleCodegen : string = 'data-testid' ;
2024-10-03 08:48:26 +02:00
private _markedElements ? : { callId : string , elements : Set < Element > } ;
2023-02-17 20:19:53 +01:00
// eslint-disable-next-line no-restricted-globals
readonly window : Window & typeof globalThis ;
readonly document : Document ;
2024-08-27 20:52:14 +02:00
// Recorder must use any external dependencies through InjectedScript.
// Otherwise it will end up with a copy of all modules it uses, and any
// module-level globals will be duplicated, which leads to subtle bugs.
readonly utils = {
asLocator ,
cacheNormalizedWhitespaces ,
elementText ,
getAriaRole ,
getElementAccessibleDescription ,
getElementAccessibleName ,
isElementVisible ,
isInsideScope ,
normalizeWhiteSpace ,
2024-11-08 16:43:01 +01:00
parseYamlTemplate ,
2024-08-27 20:52:14 +02:00
} ;
2023-02-17 20:19:53 +01:00
// eslint-disable-next-line no-restricted-globals
constructor ( window : Window & typeof globalThis , isUnderTest : boolean , sdkLanguage : Language , testIdAttributeNameForStrictErrorAndConsoleCodegen : string , stableRafCount : number , browserName : string , customEngines : { name : string , engine : SelectorEngine } [ ] ) {
this . window = window ;
this . document = window . document ;
2022-03-01 22:56:21 +01:00
this . isUnderTest = isUnderTest ;
2022-10-18 22:09:54 +02:00
this . _sdkLanguage = sdkLanguage ;
2022-11-08 21:04:43 +01:00
this . _testIdAttributeNameForStrictErrorAndConsoleCodegen = testIdAttributeNameForStrictErrorAndConsoleCodegen ;
2021-07-27 00:07:12 +02:00
this . _evaluator = new SelectorEvaluatorImpl ( new Map ( ) ) ;
this . _engines = new Map ( ) ;
this . _engines . set ( 'xpath' , XPathEngine ) ;
this . _engines . set ( 'xpath:light' , XPathEngine ) ;
2021-08-11 02:21:16 +02:00
this . _engines . set ( '_react' , ReactEngine ) ;
this . _engines . set ( '_vue' , VueEngine ) ;
2022-11-12 00:58:36 +01:00
this . _engines . set ( 'role' , createRoleEngine ( false ) ) ;
2022-10-18 22:09:54 +02:00
this . _engines . set ( 'text' , this . _createTextEngine ( true , false ) ) ;
this . _engines . set ( 'text:light' , this . _createTextEngine ( false , false ) ) ;
2021-07-27 00:07:12 +02:00
this . _engines . set ( 'id' , this . _createAttributeEngine ( 'id' , true ) ) ;
this . _engines . set ( 'id:light' , this . _createAttributeEngine ( 'id' , false ) ) ;
this . _engines . set ( 'data-testid' , this . _createAttributeEngine ( 'data-testid' , true ) ) ;
this . _engines . set ( 'data-testid:light' , this . _createAttributeEngine ( 'data-testid' , false ) ) ;
this . _engines . set ( 'data-test-id' , this . _createAttributeEngine ( 'data-test-id' , true ) ) ;
this . _engines . set ( 'data-test-id:light' , this . _createAttributeEngine ( 'data-test-id' , false ) ) ;
this . _engines . set ( 'data-test' , this . _createAttributeEngine ( 'data-test' , true ) ) ;
this . _engines . set ( 'data-test:light' , this . _createAttributeEngine ( 'data-test' , false ) ) ;
this . _engines . set ( 'css' , this . _createCSSEngine ( ) ) ;
2021-08-11 20:06:09 +02:00
this . _engines . set ( 'nth' , { queryAll : ( ) = > [ ] } ) ;
2022-04-27 21:51:57 +02:00
this . _engines . set ( 'visible' , this . _createVisibleEngine ( ) ) ;
2022-10-05 17:45:10 +02:00
this . _engines . set ( 'internal:control' , this . _createControlEngine ( ) ) ;
this . _engines . set ( 'internal:has' , this . _createHasEngine ( ) ) ;
2023-04-05 21:45:46 +02:00
this . _engines . set ( 'internal:has-not' , this . _createHasNotEngine ( ) ) ;
2023-05-05 20:14:01 +02:00
this . _engines . set ( 'internal:and' , { queryAll : ( ) = > [ ] } ) ;
2023-03-22 23:28:59 +01:00
this . _engines . set ( 'internal:or' , { queryAll : ( ) = > [ ] } ) ;
2023-07-14 21:21:45 +02:00
this . _engines . set ( 'internal:chain' , this . _createInternalChainEngine ( ) ) ;
2022-10-18 22:09:54 +02:00
this . _engines . set ( 'internal:label' , this . _createInternalLabelEngine ( ) ) ;
this . _engines . set ( 'internal:text' , this . _createTextEngine ( true , true ) ) ;
2022-10-22 01:29:45 +02:00
this . _engines . set ( 'internal:has-text' , this . _createInternalHasTextEngine ( ) ) ;
2023-04-05 23:13:28 +02:00
this . _engines . set ( 'internal:has-not-text' , this . _createInternalHasNotTextEngine ( ) ) ;
2022-10-05 17:45:10 +02:00
this . _engines . set ( 'internal:attr' , this . _createNamedAttributeEngine ( ) ) ;
2022-11-08 21:04:43 +01:00
this . _engines . set ( 'internal:testid' , this . _createNamedAttributeEngine ( ) ) ;
2022-11-12 00:58:36 +01:00
this . _engines . set ( 'internal:role' , createRoleEngine ( true ) ) ;
2021-07-27 07:00:23 +02:00
2020-12-04 15:51:18 +01:00
for ( const { name , engine } of customEngines )
2021-07-27 00:07:12 +02:00
this . _engines . set ( name , engine ) ;
2020-12-04 15:51:18 +01:00
2021-02-10 21:36:26 +01:00
this . _stableRafCount = stableRafCount ;
2021-09-25 05:51:09 +02:00
this . _browserName = browserName ;
2023-11-15 19:34:53 +01:00
setBrowserName ( browserName ) ;
2021-10-31 01:06:52 +02:00
this . _setupGlobalListenersRemovalDetection ( ) ;
2021-11-06 01:31:28 +01:00
this . _setupHitTargetInterceptors ( ) ;
2022-03-22 01:26:45 +01:00
if ( isUnderTest )
2023-02-17 20:19:53 +01:00
( this . window as any ) . __injectedScript = this ;
2020-05-16 00:21:49 +02:00
}
2024-05-31 23:44:26 +02:00
builtinSetTimeout ( callback : Function , timeout : number ) {
2024-06-07 00:56:13 +02:00
if ( this . window . __pwClock ? . builtin )
return this . window . __pwClock . builtin . setTimeout ( callback , timeout ) ;
2024-10-23 19:24:53 +02:00
return this . window . setTimeout ( callback , timeout ) ;
}
builtinClearTimeout ( timeout : number | undefined ) {
if ( this . window . __pwClock ? . builtin )
return this . window . __pwClock . builtin . clearTimeout ( timeout ) ;
return this . window . clearTimeout ( timeout ) ;
2024-05-31 23:44:26 +02:00
}
builtinRequestAnimationFrame ( callback : FrameRequestCallback ) {
2024-06-07 00:56:13 +02:00
if ( this . window . __pwClock ? . builtin )
return this . window . __pwClock . builtin . requestAnimationFrame ( callback ) ;
2024-10-23 19:24:53 +02:00
return this . window . requestAnimationFrame ( callback ) ;
2024-05-31 23:44:26 +02:00
}
2021-09-23 06:49:14 +02:00
eval ( expression : string ) : any {
2023-02-17 20:19:53 +01:00
return this . window . eval ( expression ) ;
2021-09-23 06:49:14 +02:00
}
2022-11-08 21:04:43 +01:00
testIdAttributeNameForStrictErrorAndConsoleCodegen ( ) : string {
return this . _testIdAttributeNameForStrictErrorAndConsoleCodegen ;
}
2020-09-07 03:19:32 +02:00
parseSelector ( selector : string ) : ParsedSelector {
2020-12-18 02:01:46 +01:00
const result = parseSelector ( selector ) ;
2023-06-20 00:22:26 +02:00
visitAllSelectorParts ( result , part = > {
if ( ! this . _engines . has ( part . name ) )
throw this . createStacklessError ( ` Unknown engine " ${ part . name } " while parsing selector ${ selector } ` ) ;
} ) ;
2020-12-18 02:01:46 +01:00
return result ;
2020-09-07 03:19:32 +02:00
}
2024-01-23 20:29:40 +01:00
generateSelector ( targetElement : Element , options : GenerateSelectorOptions ) {
return generateSelector ( this , targetElement , options ) ;
}
generateSelectorSimple ( targetElement : Element , options? : GenerateSelectorOptions ) : string {
2023-06-14 06:25:39 +02:00
return generateSelector ( this , targetElement , { . . . options , testIdAttributeName : this._testIdAttributeNameForStrictErrorAndConsoleCodegen } ) . selector ;
2022-02-05 04:27:45 +01:00
}
2021-07-27 07:00:23 +02:00
querySelector ( selector : ParsedSelector , root : Node , strict : boolean ) : Element | undefined {
2022-04-27 21:51:57 +02:00
const result = this . querySelectorAll ( selector , root ) ;
if ( strict && result . length > 1 )
throw this . strictModeViolationError ( selector , result ) ;
return result [ 0 ] ;
2020-05-16 00:21:49 +02:00
}
2022-05-03 11:33:33 +02:00
private _queryNth ( elements : Set < Element > , part : ParsedSelectorPart ) : Set < Element > {
const list = [ . . . elements ] ;
2022-04-27 21:51:57 +02:00
let nth = + part . body ;
if ( nth === - 1 )
nth = list . length - 1 ;
return new Set < Element > ( list . slice ( nth , nth + 1 ) ) ;
}
2021-07-27 21:53:12 +02:00
2022-05-03 11:33:33 +02:00
private _queryLayoutSelector ( elements : Set < Element > , part : ParsedSelectorPart , originalRoot : Node ) : Set < Element > {
const name = part . name as LayoutSelectorName ;
const body = part . body as NestedSelectorBody ;
const result : { element : Element , score : number } [ ] = [ ] ;
const inner = this . querySelectorAll ( body . parsed , originalRoot ) ;
for ( const element of elements ) {
const score = layoutSelectorScore ( name , element , inner , body . distance ) ;
if ( score !== undefined )
result . push ( { element , score } ) ;
}
result . sort ( ( a , b ) = > a . score - b . score ) ;
return new Set < Element > ( result . map ( r = > r . element ) ) ;
}
2024-11-15 22:48:43 +01:00
ariaSnapshot ( node : Node , options ? : { mode ? : 'raw' | 'regex' , id? : boolean } ) : string {
2024-10-16 03:47:26 +02:00
if ( node . nodeType !== Node . ELEMENT_NODE )
throw this . createStacklessError ( 'Can only capture aria snapshot of Element nodes.' ) ;
2024-11-15 22:48:43 +01:00
const ariaSnapshot = generateAriaTree ( node as Element ) ;
return renderAriaTree ( ariaSnapshot . root , options ) ;
}
ariaSnapshotAsObject ( node : Node ) : AriaSnapshot {
return generateAriaTree ( node as Element ) ;
}
ariaSnapshotElement ( snapshot : AriaSnapshot , elementId : number ) : Element | null {
return snapshot . elements . get ( elementId ) || null ;
}
renderAriaTree ( ariaNode : AriaNode , options ? : { mode ? : 'raw' | 'regex' , id? : boolean } ) : string {
return renderAriaTree ( ariaNode , options ) ;
}
renderAriaSnapshotWithIds ( ariaSnapshot : AriaSnapshot ) : string {
return renderAriaTree ( ariaSnapshot . root , { ids : ariaSnapshot.ids } ) ;
2024-10-15 22:38:55 +02:00
}
2024-11-08 16:43:01 +01:00
getAllByAria ( document : Document , template : AriaTemplateNode ) : Element [ ] {
return getAllByAria ( document . documentElement , template ) ;
}
2022-04-27 21:51:57 +02:00
querySelectorAll ( selector : ParsedSelector , root : Node ) : Element [ ] {
if ( selector . capture !== undefined ) {
if ( selector . parts . some ( part = > part . name === 'nth' ) )
throw this . createStacklessError ( ` Can't query n-th element in a request with the capture. ` ) ;
const withHas : ParsedSelector = { parts : selector.parts.slice ( 0 , selector . capture + 1 ) } ;
if ( selector . capture < selector . parts . length - 1 ) {
2022-05-03 11:33:33 +02:00
const parsed : ParsedSelector = { parts : selector.parts.slice ( selector . capture + 1 ) } ;
2022-10-05 17:45:10 +02:00
const has : ParsedSelectorPart = { name : 'internal:has' , body : { parsed } , source : stringifySelector ( parsed ) } ;
2022-04-27 21:51:57 +02:00
withHas . parts . push ( has ) ;
2021-08-23 22:54:02 +02:00
}
2022-04-27 21:51:57 +02:00
return this . querySelectorAll ( withHas , root ) ;
2020-05-16 00:21:49 +02:00
}
if ( ! ( root as any ) [ 'querySelectorAll' ] )
2021-08-27 06:21:19 +02:00
throw this . createStacklessError ( 'Node is not queryable.' ) ;
2022-04-27 21:51:57 +02:00
if ( selector . capture !== undefined ) {
// We should have handled the capture above.
throw this . createStacklessError ( 'Internal error: there should not be a capture in the selector.' ) ;
}
2023-08-24 21:59:42 +02:00
// Workaround so that ":scope" matches the ShadowRoot.
// This is, unfortunately, because an ElementHandle can point to any Node (including ShadowRoot/Document/etc),
// and not just to an Element, and we support various APIs on ElementHandle like "textContent()".
if ( root . nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ && selector . parts . length === 1 && selector . parts [ 0 ] . name === 'css' && selector . parts [ 0 ] . source === ':scope' )
return [ root as Element ] ;
2021-02-09 06:53:17 +01:00
this . _evaluator . begin ( ) ;
try {
2022-04-27 21:51:57 +02:00
let roots = new Set < Element > ( [ root as Element ] ) ;
for ( const part of selector . parts ) {
if ( part . name === 'nth' ) {
roots = this . _queryNth ( roots , part ) ;
2023-05-05 20:14:01 +02:00
} else if ( part . name === 'internal:and' ) {
const andElements = this . querySelectorAll ( ( part . body as NestedSelectorBody ) . parsed , root ) ;
roots = new Set ( andElements . filter ( e = > roots . has ( e ) ) ) ;
2023-03-22 23:28:59 +01:00
} else if ( part . name === 'internal:or' ) {
const orElements = this . querySelectorAll ( ( part . body as NestedSelectorBody ) . parsed , root ) ;
roots = new Set ( sortInDOMOrder ( new Set ( [ . . . roots , . . . orElements ] ) ) ) ;
2022-05-03 11:33:33 +02:00
} else if ( kLayoutSelectorNames . includes ( part . name as LayoutSelectorName ) ) {
roots = this . _queryLayoutSelector ( roots , part , root ) ;
2022-04-27 21:51:57 +02:00
} else {
const next = new Set < Element > ( ) ;
for ( const root of roots ) {
const all = this . _queryEngineAll ( part , root ) ;
for ( const one of all )
next . add ( one ) ;
}
roots = next ;
}
}
return [ . . . roots ] ;
2021-02-09 06:53:17 +01:00
} finally {
this . _evaluator . end ( ) ;
2021-02-05 02:44:55 +01:00
}
2020-12-18 02:01:46 +01:00
}
private _queryEngineAll ( part : ParsedSelectorPart , root : SelectorRoot ) : Element [ ] {
2022-04-27 21:51:57 +02:00
const result = this . _engines . get ( part . name ) ! . queryAll ( root , part . body ) ;
for ( const element of result ) {
if ( ! ( 'nodeName' in element ) )
throw this . createStacklessError ( ` Expected a Node but got ${ Object . prototype . toString . call ( element ) } ` ) ;
}
return result ;
2020-05-16 00:21:49 +02:00
}
2021-01-08 23:51:43 +01:00
private _createAttributeEngine ( attribute : string , shadow : boolean ) : SelectorEngine {
const toCSS = ( selector : string ) : CSSComplexSelectorList = > {
const css = ` [ ${ attribute } = ${ JSON . stringify ( selector ) } ] ` ;
return [ { simples : [ { selector : { css , functions : [ ] } , combinator : '' } ] } ] ;
} ;
return {
2021-02-05 02:44:55 +01:00
queryAll : ( root : SelectorRoot , selector : string ) : Element [ ] = > {
return this . _evaluator . query ( { scope : root as Document | Element , pierceShadow : shadow } , toCSS ( selector ) ) ;
}
} ;
}
2022-12-05 23:08:54 +01:00
private _createCSSEngine ( ) : SelectorEngine {
2021-07-27 00:07:12 +02:00
return {
2022-12-05 23:08:54 +01:00
queryAll : ( root : SelectorRoot , body : any ) = > {
return this . _evaluator . query ( { scope : root as Document | Element , pierceShadow : true } , body ) ;
2021-07-27 00:07:12 +02:00
}
} ;
}
2022-10-18 22:09:54 +02:00
private _createTextEngine ( shadow : boolean , internal : boolean ) : SelectorEngine {
2022-12-05 23:08:54 +01:00
const queryAll = ( root : SelectorRoot , selector : string ) : Element [ ] = > {
2022-10-22 01:29:45 +02:00
const { matcher , kind } = createTextMatcher ( selector , internal ) ;
2021-02-10 06:31:46 +01:00
const result : Element [ ] = [ ] ;
let lastDidNotMatchSelf : Element | null = null ;
2021-07-27 00:07:12 +02:00
const appendElement = ( element : Element ) = > {
2021-02-10 06:31:46 +01:00
// TODO: replace contains() with something shadow-dom-aware?
2021-03-03 19:51:10 +01:00
if ( kind === 'lax' && lastDidNotMatchSelf && lastDidNotMatchSelf . contains ( element ) )
2021-02-10 06:31:46 +01:00
return false ;
2022-05-11 14:49:12 +02:00
const matches = elementMatchesText ( this . _evaluator . _cacheText , element , matcher ) ;
2021-02-10 06:31:46 +01:00
if ( matches === 'none' )
lastDidNotMatchSelf = element ;
2022-10-22 01:29:45 +02:00
if ( matches === 'self' || ( matches === 'selfAndChildren' && kind === 'strict' && ! internal ) )
2021-02-10 06:31:46 +01:00
result . push ( element ) ;
} ;
2021-07-27 21:53:12 +02:00
if ( root . nodeType === Node . ELEMENT_NODE )
appendElement ( root as Element ) ;
2021-02-10 06:31:46 +01:00
const elements = this . _evaluator . _queryCSS ( { scope : root as Document | Element , pierceShadow : shadow } , '*' ) ;
2021-07-27 21:53:12 +02:00
for ( const element of elements )
appendElement ( element ) ;
2021-02-10 06:31:46 +01:00
return result ;
} ;
2022-12-05 23:08:54 +01:00
return { queryAll } ;
2022-09-30 03:12:49 +02:00
}
2022-10-22 01:29:45 +02:00
private _createInternalHasTextEngine ( ) : SelectorEngine {
return {
queryAll : ( root : SelectorRoot , selector : string ) : Element [ ] = > {
if ( root . nodeType !== 1 /* Node.ELEMENT_NODE */ )
return [ ] ;
const element = root as Element ;
2022-12-05 23:08:54 +01:00
const text = elementText ( this . _evaluator . _cacheText , element ) ;
2022-10-22 01:29:45 +02:00
const { matcher } = createTextMatcher ( selector , true ) ;
return matcher ( text ) ? [ element ] : [ ] ;
}
} ;
}
2023-04-05 23:13:28 +02:00
private _createInternalHasNotTextEngine ( ) : SelectorEngine {
return {
queryAll : ( root : SelectorRoot , selector : string ) : Element [ ] = > {
if ( root . nodeType !== 1 /* Node.ELEMENT_NODE */ )
return [ ] ;
const element = root as Element ;
const text = elementText ( this . _evaluator . _cacheText , element ) ;
const { matcher } = createTextMatcher ( selector , true ) ;
return matcher ( text ) ? [ ] : [ element ] ;
}
} ;
}
2022-10-18 22:09:54 +02:00
private _createInternalLabelEngine ( ) : SelectorEngine {
2022-10-05 17:45:10 +02:00
return {
queryAll : ( root : SelectorRoot , selector : string ) : Element [ ] = > {
2022-10-22 01:29:45 +02:00
const { matcher } = createTextMatcher ( selector , true ) ;
2022-12-14 22:51:05 +01:00
const allElements = this . _evaluator . _queryCSS ( { scope : root as Document | Element , pierceShadow : true } , '*' ) ;
return allElements . filter ( element = > {
2023-06-22 17:34:08 +02:00
return getElementLabels ( this . _evaluator . _cacheText , element ) . some ( label = > matcher ( label ) ) ;
2022-12-14 22:51:05 +01:00
} ) ;
2022-10-05 17:45:10 +02:00
}
} ;
}
2022-09-30 03:12:49 +02:00
private _createNamedAttributeEngine ( ) : SelectorEngine {
2022-12-05 23:08:54 +01:00
const queryAll = ( root : SelectorRoot , selector : string ) : Element [ ] = > {
2022-09-30 03:12:49 +02:00
const parsed = parseAttributeSelector ( selector , true ) ;
if ( parsed . name || parsed . attributes . length !== 1 )
throw new Error ( 'Malformed attribute selector: ' + selector ) ;
const { name , value , caseSensitive } = parsed . attributes [ 0 ] ;
const lowerCaseValue = caseSensitive ? null : value . toLowerCase ( ) ;
let matcher : ( s : string ) = > boolean ;
if ( value instanceof RegExp )
matcher = s = > ! ! s . match ( value ) ;
else if ( caseSensitive )
matcher = s = > s === value ;
else
matcher = s = > s . toLowerCase ( ) . includes ( lowerCaseValue ! ) ;
const elements = this . _evaluator . _queryCSS ( { scope : root as Document | Element , pierceShadow : true } , ` [ ${ name } ] ` ) ;
return elements . filter ( e = > matcher ( e . getAttribute ( name ) ! ) ) ;
} ;
2022-12-05 23:08:54 +01:00
return { queryAll } ;
2021-01-08 23:51:43 +01:00
}
2022-12-05 23:08:54 +01:00
private _createControlEngine ( ) : SelectorEngine {
2021-11-08 18:58:24 +01:00
return {
queryAll ( root : SelectorRoot , body : any ) {
if ( body === 'enter-frame' )
return [ ] ;
if ( body === 'return-empty' )
return [ ] ;
2022-09-22 00:12:18 +02:00
if ( body === 'component' ) {
if ( root . nodeType !== 1 /* Node.ELEMENT_NODE */ )
return [ ] ;
// Usually, we return the mounted component that is a single child.
// However, when mounting fragments, return the root instead.
return [ root . childElementCount === 1 ? root . firstElementChild ! : root as Element ] ;
}
2022-10-05 17:45:10 +02:00
throw new Error ( ` Internal error, unknown internal:control selector ${ body } ` ) ;
2021-11-08 18:58:24 +01:00
}
} ;
}
2022-12-05 23:08:54 +01:00
private _createHasEngine ( ) : SelectorEngine {
2022-05-03 11:33:33 +02:00
const queryAll = ( root : SelectorRoot , body : NestedSelectorBody ) = > {
2022-02-03 01:55:50 +01:00
if ( root . nodeType !== 1 /* Node.ELEMENT_NODE */ )
return [ ] ;
2022-05-03 11:33:33 +02:00
const has = ! ! this . querySelector ( body . parsed , root , false ) ;
2022-02-03 01:55:50 +01:00
return has ? [ root as Element ] : [ ] ;
} ;
return { queryAll } ;
}
2023-04-05 21:45:46 +02:00
private _createHasNotEngine ( ) : SelectorEngine {
const queryAll = ( root : SelectorRoot , body : NestedSelectorBody ) = > {
if ( root . nodeType !== 1 /* Node.ELEMENT_NODE */ )
return [ ] ;
const has = ! ! this . querySelector ( body . parsed , root , false ) ;
return has ? [ ] : [ root as Element ] ;
} ;
return { queryAll } ;
}
2022-12-05 23:08:54 +01:00
private _createVisibleEngine ( ) : SelectorEngine {
2022-04-27 21:51:57 +02:00
const queryAll = ( root : SelectorRoot , body : string ) = > {
if ( root . nodeType !== 1 /* Node.ELEMENT_NODE */ )
return [ ] ;
2024-12-16 23:14:51 +01:00
const visible = body === 'true' ;
return isElementVisible ( root as Element ) === visible ? [ root as Element ] : [ ] ;
2022-04-27 21:51:57 +02:00
} ;
return { queryAll } ;
}
2023-07-14 21:21:45 +02:00
private _createInternalChainEngine ( ) : SelectorEngine {
const queryAll = ( root : SelectorRoot , body : NestedSelectorBody ) = > {
return this . querySelectorAll ( body . parsed , root ) ;
} ;
return { queryAll } ;
}
2020-09-07 03:19:32 +02:00
extend ( source : string , params : any ) : any {
2023-02-17 20:19:53 +01:00
const constrFunction = this . window . eval ( `
2021-01-09 01:15:05 +01:00
( ( ) = > {
2022-03-29 08:10:17 +02:00
const module = { } ;
2021-01-09 01:15:05 +01:00
$ { source }
2023-03-06 05:01:35 +01:00
return module .exports.default ( ) ;
2021-01-09 01:15:05 +01:00
} ) ( ) ` );
2020-09-07 03:19:32 +02:00
return new constrFunction ( this , params ) ;
}
2023-01-07 01:56:24 +01:00
async viewportRatio ( element : Element ) : Promise < number > {
return await new Promise ( resolve = > {
const observer = new IntersectionObserver ( entries = > {
resolve ( entries [ 0 ] . intersectionRatio ) ;
observer . disconnect ( ) ;
} ) ;
observer . observe ( element ) ;
// Firefox doesn't call IntersectionObserver callback unless
// there are rafs.
2024-05-31 23:44:26 +02:00
this . builtinRequestAnimationFrame ( ( ) = > { } ) ;
2023-01-07 01:56:24 +01:00
} ) ;
}
2020-02-25 16:06:20 +01:00
getElementBorderWidth ( node : Node ) : { left : number ; top : number ; } {
if ( node . nodeType !== Node . ELEMENT_NODE || ! node . ownerDocument || ! node . ownerDocument . defaultView )
return { left : 0 , top : 0 } ;
const style = node . ownerDocument . defaultView . getComputedStyle ( node as Element ) ;
return { left : parseInt ( style . borderLeftWidth || '' , 10 ) , top : parseInt ( style . borderTopWidth || '' , 10 ) } ;
}
2022-12-28 01:59:34 +01:00
describeIFrameStyle ( iframe : Element ) : 'error:notconnected' | 'transformed' | { left : number , top : number } {
2022-11-19 01:51:39 +01:00
if ( ! iframe . ownerDocument || ! iframe . ownerDocument . defaultView )
return 'error:notconnected' ;
const defaultView = iframe . ownerDocument . defaultView ;
for ( let e : Element | undefined = iframe ; e ; e = parentElementOrShadowHost ( e ) ) {
if ( defaultView . getComputedStyle ( e ) . transform !== 'none' )
return 'transformed' ;
}
const iframeStyle = defaultView . getComputedStyle ( iframe ) ;
2022-12-28 01:59:34 +01:00
return {
left : parseInt ( iframeStyle . borderLeftWidth || '' , 10 ) + parseInt ( iframeStyle . paddingLeft || '' , 10 ) ,
top : parseInt ( iframeStyle . borderTopWidth || '' , 10 ) + parseInt ( iframeStyle . paddingTop || '' , 10 ) ,
} ;
2022-11-19 01:51:39 +01:00
}
2022-08-11 22:06:12 +02:00
retarget ( node : Node , behavior : 'none' | 'follow-label' | 'no-follow-label' | 'button-link' ) : Element | null {
2021-02-10 21:36:26 +01:00
let element = node . nodeType === Node . ELEMENT_NODE ? node as Element : node.parentElement ;
if ( ! element )
return null ;
2022-07-27 23:02:35 +02:00
if ( behavior === 'none' )
return element ;
2024-04-01 22:03:09 +02:00
if ( ! element . matches ( 'input, textarea, select' ) && ! ( element as any ) . isContentEditable ) {
2022-08-11 22:06:12 +02:00
if ( behavior === 'button-link' )
element = element . closest ( 'button, [role=button], a, [role=link]' ) || element ;
else
element = element . closest ( 'button, [role=button], [role=checkbox], [role=radio]' ) || element ;
}
2021-03-03 02:29:03 +01:00
if ( behavior === 'follow-label' ) {
2024-06-18 21:12:19 +02:00
if ( ! element . matches ( 'a, input, textarea, button, select, [role=link], [role=button], [role=checkbox], [role=radio]' ) &&
2022-07-12 23:17:10 +02:00
! ( element as any ) . isContentEditable ) {
2021-03-03 02:29:03 +01:00
// Go up to the label that might be connected to the input/textarea.
2025-01-07 20:15:00 +01:00
const enclosingLabel : HTMLLabelElement | null = element . closest ( 'label' ) ;
if ( enclosingLabel && enclosingLabel . control )
element = enclosingLabel . control ;
2021-03-03 02:29:03 +01:00
}
2021-02-10 21:36:26 +01:00
}
return element ;
2020-02-25 16:06:20 +01:00
}
2024-01-17 04:11:41 +01:00
async checkElementStates ( node : Node , states : ElementState [ ] ) : Promise < 'error:notconnected' | { missingState : ElementState } | undefined > {
if ( states . includes ( 'stable' ) ) {
const stableResult = await this . _checkElementIsStable ( node ) ;
if ( stableResult === false )
return { missingState : 'stable' } ;
if ( stableResult === 'error:notconnected' )
2025-01-08 00:31:18 +01:00
return 'error:notconnected' ;
2024-01-17 04:11:41 +01:00
}
for ( const state of states ) {
if ( state !== 'stable' ) {
const result = this . elementState ( node , state ) ;
2025-01-08 00:31:18 +01:00
if ( result . received === 'error:notconnected' )
return 'error:notconnected' ;
if ( ! result . matches )
2024-01-17 04:11:41 +01:00
return { missingState : state } ;
}
}
}
private async _checkElementIsStable ( node : Node ) : Promise < 'error:notconnected' | boolean > {
const continuePolling = Symbol ( 'continuePolling' ) ;
2021-02-10 21:36:26 +01:00
let lastRect : { x : number , y : number , width : number , height : number } | undefined ;
2024-01-17 04:11:41 +01:00
let stableRafCounter = 0 ;
2021-02-10 21:36:26 +01:00
let lastTime = 0 ;
2021-01-25 22:40:19 +01:00
2024-01-17 04:11:41 +01:00
const check = ( ) = > {
const element = this . retarget ( node , 'no-follow-label' ) ;
if ( ! element )
return 'error:notconnected' ;
// Drop frames that are shorter than 16ms - WebKit Win bug.
const time = performance . now ( ) ;
if ( this . _stableRafCount > 1 && time - lastTime < 15 )
return continuePolling ;
lastTime = time ;
const clientRect = element . getBoundingClientRect ( ) ;
const rect = { x : clientRect.top , y : clientRect.left , width : clientRect.width , height : clientRect.height } ;
if ( lastRect ) {
const samePosition = rect . x === lastRect . x && rect . y === lastRect . y && rect . width === lastRect . width && rect . height === lastRect . height ;
if ( ! samePosition )
return false ;
if ( ++ stableRafCounter >= this . _stableRafCount )
return true ;
2021-06-24 17:18:09 +02:00
}
2024-01-17 04:11:41 +01:00
lastRect = rect ;
return continuePolling ;
} ;
2021-06-24 17:18:09 +02:00
2024-01-17 04:11:41 +01:00
let fulfill : ( result : 'error:notconnected' | boolean ) = > void ;
let reject : ( error : Error ) = > void ;
const result = new Promise < 'error:notconnected' | boolean > ( ( f , r ) = > { fulfill = f ; reject = r ; } ) ;
2021-02-10 21:36:26 +01:00
2024-01-17 04:11:41 +01:00
const raf = ( ) = > {
try {
const success = check ( ) ;
if ( success !== continuePolling )
fulfill ( success ) ;
2021-02-10 21:36:26 +01:00
else
2024-05-31 23:44:26 +02:00
this . builtinRequestAnimationFrame ( raf ) ;
2024-01-17 04:11:41 +01:00
} catch ( e ) {
reject ( e ) ;
2020-04-07 19:07:06 +02:00
}
2024-01-17 04:11:41 +01:00
} ;
2024-05-31 23:44:26 +02:00
this . builtinRequestAnimationFrame ( raf ) ;
2021-02-10 21:36:26 +01:00
2024-01-17 04:11:41 +01:00
return result ;
2021-02-10 21:36:26 +01:00
}
2025-01-08 00:31:18 +01:00
elementState ( node : Node , state : ElementStateWithoutStable ) : ElementStateQueryResult {
const element = this . retarget ( node , [ 'visible' , 'hidden' ] . includes ( state ) ? 'none' : 'follow-label' ) ;
2021-02-10 21:36:26 +01:00
if ( ! element || ! element . isConnected ) {
if ( state === 'hidden' )
2025-01-08 00:31:18 +01:00
return { matches : true , received : 'hidden' } ;
return { matches : false , received : 'error:notconnected' } ;
2021-02-10 21:36:26 +01:00
}
2021-03-03 02:29:03 +01:00
2025-01-08 00:31:18 +01:00
if ( state === 'visible' || state === 'hidden' ) {
const visible = isElementVisible ( element ) ;
return {
matches : state === 'visible' ? visible : ! visible ,
received : visible ? 'visible' : 'hidden'
} ;
}
2021-02-10 21:36:26 +01:00
2025-01-08 00:31:18 +01:00
if ( state === 'disabled' || state === 'enabled' ) {
const disabled = getAriaDisabled ( element ) ;
return {
matches : state === 'disabled' ? disabled : ! disabled ,
received : disabled ? 'disabled' : 'enabled'
} ;
}
2021-02-10 21:36:26 +01:00
2024-11-22 12:40:43 +01:00
if ( state === 'editable' ) {
2025-01-08 00:31:18 +01:00
const disabled = getAriaDisabled ( element ) ;
2024-11-22 12:40:43 +01:00
const readonly = getReadonly ( element ) ;
if ( readonly === 'error' )
throw this . createStacklessError ( 'Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]' ) ;
2025-01-08 00:31:18 +01:00
return {
matches : ! disabled && ! readonly ,
received : disabled ? 'disabled' : readonly ? 'readOnly' : 'editable'
} ;
2024-11-22 12:40:43 +01:00
}
2021-02-10 21:36:26 +01:00
2025-01-08 00:31:18 +01:00
if ( state === 'checked' || state === 'unchecked' || state === 'mixed' ) {
const need = state === 'checked' ? true : state === 'unchecked' ? false : 'mixed' ;
2023-02-11 03:56:45 +01:00
const checked = getChecked ( element , false ) ;
2022-10-25 15:11:11 +02:00
if ( checked === 'error' )
2021-09-25 05:51:09 +02:00
throw this . createStacklessError ( 'Not a checkbox or radio button' ) ;
2025-01-08 00:31:18 +01:00
return {
matches : need === checked ,
received : checked === true ? 'checked' : checked === false ? 'unchecked' : 'mixed' ,
} ;
2021-02-10 21:36:26 +01:00
}
2021-08-27 06:21:19 +02:00
throw this . createStacklessError ( ` Unexpected element state " ${ state } " ` ) ;
2021-02-10 21:36:26 +01:00
}
2024-01-17 04:11:41 +01:00
selectOptions ( node : Node , optionsToSelect : ( Node | { valueOrLabel? : string , value? : string , label? : string , index? : number } ) [ ] ) : string [ ] | 'error:notconnected' | 'error:optionsnotfound' {
2021-06-28 23:18:01 +02:00
const element = this . retarget ( node , 'follow-label' ) ;
2021-02-10 21:36:26 +01:00
if ( ! element )
return 'error:notconnected' ;
if ( element . nodeName . toLowerCase ( ) !== 'select' )
2021-09-25 05:51:09 +02:00
throw this . createStacklessError ( 'Element is not a <select> element' ) ;
2021-02-10 21:36:26 +01:00
const select = element as HTMLSelectElement ;
2021-06-04 00:10:02 +02:00
const options = [ . . . select . options ] ;
2021-02-10 21:36:26 +01:00
const selectedOptions = [ ] ;
let remainingOptionsToSelect = optionsToSelect . slice ( ) ;
for ( let index = 0 ; index < options . length ; index ++ ) {
const option = options [ index ] ;
2022-12-07 18:04:32 +01:00
const filter = ( optionToSelect : Node | { valueOrLabel? : string , value? : string , label? : string , index? : number } ) = > {
2021-02-10 21:36:26 +01:00
if ( optionToSelect instanceof Node )
return option === optionToSelect ;
let matches = true ;
2022-12-07 18:04:32 +01:00
if ( optionToSelect . valueOrLabel !== undefined )
matches = matches && ( optionToSelect . valueOrLabel === option . value || optionToSelect . valueOrLabel === option . label ) ;
2021-02-10 21:36:26 +01:00
if ( optionToSelect . value !== undefined )
matches = matches && optionToSelect . value === option . value ;
if ( optionToSelect . label !== undefined )
matches = matches && optionToSelect . label === option . label ;
if ( optionToSelect . index !== undefined )
matches = matches && optionToSelect . index === index ;
return matches ;
} ;
if ( ! remainingOptionsToSelect . some ( filter ) )
continue ;
selectedOptions . push ( option ) ;
if ( select . multiple ) {
remainingOptionsToSelect = remainingOptionsToSelect . filter ( o = > ! filter ( o ) ) ;
} else {
remainingOptionsToSelect = [ ] ;
break ;
2020-06-23 22:02:31 +02:00
}
2021-02-10 21:36:26 +01:00
}
2024-01-17 04:11:41 +01:00
if ( remainingOptionsToSelect . length )
return 'error:optionsnotfound' ;
2021-02-10 21:36:26 +01:00
select . value = undefined as any ;
selectedOptions . forEach ( option = > option . selected = true ) ;
2023-12-22 00:25:16 +01:00
select . dispatchEvent ( new Event ( 'input' , { bubbles : true , composed : true } ) ) ;
select . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
2021-02-10 21:36:26 +01:00
return selectedOptions . map ( option = > option . value ) ;
2020-04-15 02:09:26 +02:00
}
2024-01-17 04:11:41 +01:00
fill ( node : Node , value : string ) : 'error:notconnected' | 'needsinput' | 'done' {
2021-06-28 23:18:01 +02:00
const element = this . retarget ( node , 'follow-label' ) ;
2021-02-10 21:36:26 +01:00
if ( ! element )
return 'error:notconnected' ;
if ( element . nodeName . toLowerCase ( ) === 'input' ) {
const input = element as HTMLInputElement ;
const type = input . type . toLowerCase ( ) ;
2023-12-22 00:25:16 +01:00
const kInputTypesToSetValue = new Set ( [ 'color' , 'date' , 'time' , 'datetime-local' , 'month' , 'range' , 'week' ] ) ;
2021-12-07 00:43:10 +01:00
const kInputTypesToTypeInto = new Set ( [ '' , 'email' , 'number' , 'password' , 'search' , 'tel' , 'text' , 'url' ] ) ;
2024-01-17 04:11:41 +01:00
if ( ! kInputTypesToTypeInto . has ( type ) && ! kInputTypesToSetValue . has ( type ) )
2021-09-25 05:51:09 +02:00
throw this . createStacklessError ( ` Input of type " ${ type } " cannot be filled ` ) ;
2021-02-10 21:36:26 +01:00
if ( type === 'number' ) {
value = value . trim ( ) ;
if ( isNaN ( Number ( value ) ) )
2021-09-25 05:51:09 +02:00
throw this . createStacklessError ( 'Cannot type text into input[type=number]' ) ;
2020-06-23 22:02:31 +02:00
}
2021-12-07 00:43:10 +01:00
if ( kInputTypesToSetValue . has ( type ) ) {
2021-02-10 21:36:26 +01:00
value = value . trim ( ) ;
input . focus ( ) ;
input . value = value ;
if ( input . value !== value )
2021-09-25 05:51:09 +02:00
throw this . createStacklessError ( 'Malformed value' ) ;
2023-12-22 00:25:16 +01:00
element . dispatchEvent ( new Event ( 'input' , { bubbles : true , composed : true } ) ) ;
element . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
2021-02-10 21:36:26 +01:00
return 'done' ; // We have already changed the value, no need to input it.
}
} else if ( element . nodeName . toLowerCase ( ) === 'textarea' ) {
// Nothing to check here.
} else if ( ! ( element as HTMLElement ) . isContentEditable ) {
2021-09-25 05:51:09 +02:00
throw this . createStacklessError ( 'Element is not an <input>, <textarea> or [contenteditable] element' ) ;
2021-02-10 21:36:26 +01:00
}
this . selectText ( element ) ;
return 'needsinput' ; // Still need to input the value.
2020-06-23 22:02:31 +02:00
}
2021-03-03 02:29:03 +01:00
selectText ( node : Node ) : 'error:notconnected' | 'done' {
2021-06-28 23:18:01 +02:00
const element = this . retarget ( node , 'follow-label' ) ;
2021-02-10 21:36:26 +01:00
if ( ! element )
return 'error:notconnected' ;
2020-04-15 02:09:26 +02:00
if ( element . nodeName . toLowerCase ( ) === 'input' ) {
const input = element as HTMLInputElement ;
2020-02-25 16:06:20 +01:00
input . select ( ) ;
input . focus ( ) ;
2020-06-23 22:02:31 +02:00
return 'done' ;
2020-04-07 19:07:06 +02:00
}
if ( element . nodeName . toLowerCase ( ) === 'textarea' ) {
2020-02-25 16:06:20 +01:00
const textarea = element as HTMLTextAreaElement ;
textarea . selectionStart = 0 ;
textarea . selectionEnd = textarea . value . length ;
textarea . focus ( ) ;
2020-06-23 22:02:31 +02:00
return 'done' ;
2020-04-07 19:07:06 +02:00
}
2020-06-15 02:24:45 +02:00
const range = element . ownerDocument . createRange ( ) ;
2020-04-15 02:09:26 +02:00
range . selectNodeContents ( element ) ;
2020-06-15 02:24:45 +02:00
const selection = element . ownerDocument . defaultView ! . getSelection ( ) ;
2021-02-10 21:36:26 +01:00
if ( selection ) {
selection . removeAllRanges ( ) ;
selection . addRange ( range ) ;
}
2020-06-23 22:02:31 +02:00
( element as HTMLElement | SVGElement ) . focus ( ) ;
return 'done' ;
}
2022-08-09 00:34:58 +02:00
private _activelyFocused ( node : Node ) : { activeElement : Element | null , isFocused : boolean } {
const activeElement = ( node . getRootNode ( ) as ( Document | ShadowRoot ) ) . activeElement ;
const isFocused = activeElement === node && ! ! node . ownerDocument && node . ownerDocument . hasFocus ( ) ;
return { activeElement , isFocused } ;
}
2021-09-25 05:51:09 +02:00
focusNode ( node : Node , resetSelectionIfNotFocused? : boolean ) : 'error:notconnected' | 'done' {
2020-04-19 03:29:31 +02:00
if ( ! node . isConnected )
2020-06-25 00:12:17 +02:00
return 'error:notconnected' ;
if ( node . nodeType !== Node . ELEMENT_NODE )
2021-09-25 05:51:09 +02:00
throw this . createStacklessError ( 'Node is not an element' ) ;
2022-04-13 01:44:27 +02:00
2022-08-09 00:34:58 +02:00
const { activeElement , isFocused : wasFocused } = this . _activelyFocused ( node ) ;
2022-05-19 23:31:56 +02:00
if ( ( node as HTMLElement ) . isContentEditable && ! wasFocused && activeElement && ( activeElement as HTMLElement | SVGElement ) . blur ) {
2022-04-13 01:44:27 +02:00
// Workaround the Firefox bug where focusing the element does not switch current
// contenteditable to the new element. However, blurring the previous one helps.
( activeElement as HTMLElement | SVGElement ) . blur ( ) ;
}
2023-06-06 01:46:52 +02:00
// On firefox, we have to call focus() twice to actually focus an element in certain
// scenarios.
( node as HTMLElement | SVGElement ) . focus ( ) ;
2020-04-19 03:29:31 +02:00
( node as HTMLElement | SVGElement ) . focus ( ) ;
2020-07-24 18:30:31 +02:00
if ( resetSelectionIfNotFocused && ! wasFocused && node . nodeName . toLowerCase ( ) === 'input' ) {
try {
const input = node as HTMLInputElement ;
input . setSelectionRange ( 0 , 0 ) ;
} catch ( e ) {
// Some inputs do not allow selection.
}
}
2020-06-25 00:12:17 +02:00
return 'done' ;
2020-02-25 16:06:20 +01:00
}
2022-10-25 15:10:40 +02:00
blurNode ( node : Node ) : 'error:notconnected' | 'done' {
if ( ! node . isConnected )
return 'error:notconnected' ;
if ( node . nodeType !== Node . ELEMENT_NODE )
throw this . createStacklessError ( 'Node is not an element' ) ;
( node as HTMLElement | SVGElement ) . blur ( ) ;
return 'done' ;
}
2023-11-01 16:40:12 +01:00
setInputFiles ( node : Node , payloads : { name : string , mimeType : string , buffer : string , lastModifiedMs? : number } [ ] ) {
2020-04-16 19:25:28 +02:00
if ( node . nodeType !== Node . ELEMENT_NODE )
return 'Node is not of type HTMLElement' ;
const element : Element | undefined = node as Element ;
if ( element . nodeName !== 'INPUT' )
return 'Not an <input> element' ;
const input = element as HTMLInputElement ;
const type = ( input . getAttribute ( 'type' ) || '' ) . toLowerCase ( ) ;
if ( type !== 'file' )
return 'Not an input[type=file] element' ;
2020-09-03 19:09:03 +02:00
const files = payloads . map ( file = > {
const bytes = Uint8Array . from ( atob ( file . buffer ) , c = > c . charCodeAt ( 0 ) ) ;
2023-11-01 16:40:12 +01:00
return new File ( [ bytes ] , file . name , { type : file . mimeType , lastModified : file.lastModifiedMs } ) ;
2020-09-03 19:09:03 +02:00
} ) ;
2020-04-16 19:25:28 +02:00
const dt = new DataTransfer ( ) ;
for ( const file of files )
dt . items . add ( file ) ;
input . files = dt . files ;
2023-12-22 00:25:16 +01:00
input . dispatchEvent ( new Event ( 'input' , { bubbles : true , composed : true } ) ) ;
input . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
2020-04-16 19:25:28 +02:00
}
2022-09-07 02:55:15 +02:00
expectHitTarget ( hitPoint : { x : number , y : number } , targetElement : Element ) {
const roots : ( Document | ShadowRoot ) [ ] = [ ] ;
// Get all component roots leading to the target element.
// Go from the bottom to the top to make it work with closed shadow roots.
let parentElement = targetElement ;
while ( parentElement ) {
const root = enclosingShadowRootOrDocument ( parentElement ) ;
if ( ! root )
break ;
roots . push ( root ) ;
if ( root . nodeType === 9 /* Node.DOCUMENT_NODE */ )
break ;
parentElement = ( root as ShadowRoot ) . host ;
}
// Hit target in each component root should point to the next component root.
// Hit target in the last component root should point to the target or its descendant.
let hitElement : Element | undefined ;
for ( let index = roots . length - 1 ; index >= 0 ; index -- ) {
const root = roots [ index ] ;
// All browsers have different behavior around elementFromPoint and elementsFromPoint.
// https://github.com/w3c/csswg-drafts/issues/556
// http://crbug.com/1188919
const elements : Element [ ] = root . elementsFromPoint ( hitPoint . x , hitPoint . y ) ;
const singleElement = root . elementFromPoint ( hitPoint . x , hitPoint . y ) ;
if ( singleElement && elements [ 0 ] && parentElementOrShadowHost ( singleElement ) === elements [ 0 ] ) {
2023-02-17 20:19:53 +01:00
const style = this . window . getComputedStyle ( singleElement ) ;
2022-09-07 02:55:15 +02:00
if ( style ? . display === 'contents' ) {
// Workaround a case where elementsFromPoint misses the inner-most element with display:contents.
// https://bugs.chromium.org/p/chromium/issues/detail?id=1342092
elements . unshift ( singleElement ) ;
}
}
2023-03-14 03:33:56 +01:00
if ( elements [ 0 ] && elements [ 0 ] . shadowRoot === root && elements [ 1 ] === singleElement ) {
// Workaround webkit but where first two elements are swapped:
// <host>
// #shadow root
// <target>
// elementsFromPoint produces [<host>, <target>], while it should be [<target>, <host>]
// In this case, just ignore <host>.
elements . shift ( ) ;
}
2022-09-07 02:55:15 +02:00
const innerElement = elements [ 0 ] as Element | undefined ;
if ( ! innerElement )
break ;
hitElement = innerElement ;
if ( index && innerElement !== ( roots [ index - 1 ] as ShadowRoot ) . host )
break ;
}
// Check whether hit target is the target or its descendant.
2020-08-14 23:48:36 +02:00
const hitParents : Element [ ] = [ ] ;
2021-11-06 01:31:28 +01:00
while ( hitElement && hitElement !== targetElement ) {
2020-08-14 23:48:36 +02:00
hitParents . push ( hitElement ) ;
2020-12-07 00:03:36 +01:00
hitElement = parentElementOrShadowHost ( hitElement ) ;
2020-08-14 23:48:36 +02:00
}
2021-11-06 01:31:28 +01:00
if ( hitElement === targetElement )
2020-08-14 23:48:36 +02:00
return 'done' ;
2022-09-07 02:55:15 +02:00
2023-02-17 20:19:53 +01:00
const hitTargetDescription = this . previewNode ( hitParents [ 0 ] || this . document . documentElement ) ;
2020-08-14 23:48:36 +02:00
// Root is the topmost element in the hitTarget's chain that is not in the
// element's chain. For example, it might be a dialog element that overlays
// the target.
let rootHitTargetDescription : string | undefined ;
2021-11-06 01:31:28 +01:00
let element : Element | undefined = targetElement ;
2020-08-14 23:48:36 +02:00
while ( element ) {
const index = hitParents . indexOf ( element ) ;
if ( index !== - 1 ) {
if ( index > 1 )
rootHitTargetDescription = this . previewNode ( hitParents [ index - 1 ] ) ;
break ;
}
2020-12-07 00:03:36 +01:00
element = parentElementOrShadowHost ( element ) ;
2020-08-14 23:48:36 +02:00
}
if ( rootHitTargetDescription )
return { hitTargetDescription : ` ${ hitTargetDescription } from ${ rootHitTargetDescription } subtree ` } ;
return { hitTargetDescription } ;
2020-02-25 16:06:20 +01:00
}
2020-03-25 22:08:46 +01:00
2022-06-02 00:23:41 +02:00
// Life of a pointer action, for example click.
//
// 0. Retry items 1 and 2 while action fails due to navigation or element being detached.
// 1. Resolve selector to an element.
// 2. Retry the following steps until the element is detached or frame navigates away.
// 2a. Wait for the element to be stable (not moving), visible and enabled.
// 2b. Scroll element into view. Scrolling alternates between:
// - Built-in protocol scrolling.
// - Anchoring to the top/left, bottom/right and center/center.
// This is to scroll elements from under sticky headers/footers.
// 2c. Click point is calculated, either based on explicitly specified position,
// or some visible point of the element based on protocol content quads.
// 2d. Click point relative to page viewport is converted relative to the target iframe
// for the next hit-point check.
// 2e. (injected) Hit target at the click point must be a descendant of the target element.
// This prevents mis-clicking in edge cases like <iframe> overlaying the target.
// 2f. (injected) Events specific for click (or some other action type) are intercepted on
// the Window with capture:true. See 2i for details.
// Note: this step is skipped for drag&drop (see inline comments for the reason).
// 2g. Necessary keyboard modifiers are pressed.
// 2h. Click event is issued (mousemove + mousedown + mouseup).
// 2i. (injected) For each event, we check that hit target at the event point
// is a descendant of the target element.
// This guarantees no race between issuing the event and handling it in the page,
// for example due to layout shift.
// When hit target check fails, we block all future events in the page.
// 2j. Keyboard modifiers are restored.
// 2k. (injected) Event interceptor is removed.
// 2l. All navigations triggered between 2g-2k are awaited to be either committed or canceled.
// 2m. If failed, wait for increasing amount of time before the next retry.
2022-11-19 01:51:39 +01:00
setupHitTargetInterceptor ( node : Node , action : 'hover' | 'tap' | 'mouse' | 'drag' , hitPoint : { x : number , y : number } | undefined , blockAllEvents : boolean ) : HitTargetInterceptionResult | 'error:notconnected' | string /* hitTargetDescription */ {
2022-08-11 22:06:12 +02:00
const element = this . retarget ( node , 'button-link' ) ;
2022-05-18 19:01:34 +02:00
if ( ! element || ! element . isConnected )
2021-11-06 01:31:28 +01:00
return 'error:notconnected' ;
2022-11-19 01:51:39 +01:00
if ( hitPoint ) {
// First do a preliminary check, to reduce the possibility of some iframe
// intercepting the action.
const preliminaryResult = this . expectHitTarget ( hitPoint , element ) ;
if ( preliminaryResult !== 'done' )
return preliminaryResult . hitTargetDescription ;
}
2022-06-02 00:23:41 +02:00
// When dropping, the "element that is being dragged" often stays under the cursor,
// so hit target check at the moment we receive mousedown does not work -
// it finds the "element that is being dragged" instead of the
// "element that we drop onto".
if ( action === 'drag' )
return { stop : ( ) = > 'done' } ;
2021-11-06 01:31:28 +01:00
const events = {
'hover' : kHoverHitTargetInterceptorEvents ,
'tap' : kTapHitTargetInterceptorEvents ,
'mouse' : kMouseHitTargetInterceptorEvents ,
} [ action ] ;
let result : 'done' | { hitTargetDescription : string } | undefined ;
const listener = ( event : PointerEvent | MouseEvent | TouchEvent ) = > {
// Ignore events that we do not expect to intercept.
if ( ! events . has ( event . type ) )
return ;
// Playwright only issues trusted events, so allow any custom events originating from
// the page or content scripts.
if ( ! event . isTrusted )
return ;
// Determine the event point. Note that Firefox does not always have window.TouchEvent.
2023-02-17 20:19:53 +01:00
const point = ( ! ! this . window . TouchEvent && ( event instanceof this . window . TouchEvent ) ) ? event . touches [ 0 ] : ( event as MouseEvent | PointerEvent ) ;
2022-01-04 02:46:04 +01:00
// Check that we hit the right element at the first event, and assume all
// subsequent events will be fine.
2022-09-07 02:55:15 +02:00
if ( result === undefined && point )
result = this . expectHitTarget ( { x : point.clientX , y : point.clientY } , element ) ;
2022-01-04 02:46:04 +01:00
if ( blockAllEvents || ( result !== 'done' && result !== undefined ) ) {
2021-11-06 01:31:28 +01:00
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
event . stopImmediatePropagation ( ) ;
}
} ;
const stop = ( ) = > {
if ( this . _hitTargetInterceptor === listener )
this . _hitTargetInterceptor = undefined ;
2021-11-10 21:14:06 +01:00
// If we did not get any events, consider things working. Possible causes:
// - JavaScript is disabled (webkit-only).
// - Some <iframe> overlays the element from another frame.
// - Hovering a disabled control prevents any events from firing.
return result || 'done' ;
2021-11-06 01:31:28 +01:00
} ;
// Note: this removes previous listener, just in case there are two concurrent clicks
// or something went wrong and we did not cleanup.
this . _hitTargetInterceptor = listener ;
return { stop } ;
}
2025-01-03 21:16:01 +01:00
dispatchEvent ( node : Node , type : string , eventInitObj : Object ) {
2020-04-23 23:58:37 +02:00
let event ;
2025-01-03 21:16:01 +01:00
const eventInit : any = { bubbles : true , cancelable : true , composed : true , . . . eventInitObj } ;
2020-04-23 23:58:37 +02:00
switch ( eventType . get ( type ) ) {
case 'mouse' : event = new MouseEvent ( type , eventInit ) ; break ;
case 'keyboard' : event = new KeyboardEvent ( type , eventInit ) ; break ;
2025-01-03 21:16:01 +01:00
case 'touch' : {
eventInit . target ? ? = node ;
eventInit . touches = eventInit . touches ? . map ( ( t : any ) = > t instanceof Touch ? t : new Touch ( { . . . t , target : t.target ? ? node } ) ) ;
eventInit . targetTouches = eventInit . targetTouches ? . map ( ( t : any ) = > t instanceof Touch ? t : new Touch ( { . . . t , target : t.target ? ? node } ) ) ;
eventInit . changedTouches = eventInit . changedTouches ? . map ( ( t : any ) = > t instanceof Touch ? t : new Touch ( { . . . t , target : t.target ? ? node } ) ) ;
event = new TouchEvent ( type , eventInit ) ;
break ;
}
2020-04-23 23:58:37 +02:00
case 'pointer' : event = new PointerEvent ( type , eventInit ) ; break ;
case 'focus' : event = new FocusEvent ( type , eventInit ) ; break ;
case 'drag' : event = new DragEvent ( type , eventInit ) ; break ;
2022-07-13 02:17:45 +02:00
case 'wheel' : event = new WheelEvent ( type , eventInit ) ; break ;
2023-11-08 18:50:25 +01:00
case 'deviceorientation' :
try {
event = new DeviceOrientationEvent ( type , eventInit ) ;
} catch {
const { bubbles , cancelable , alpha , beta , gamma , absolute } = eventInit as { bubbles : boolean , cancelable : boolean , alpha : number , beta : number , gamma : number , absolute : boolean } ;
event = this . document . createEvent ( 'DeviceOrientationEvent' ) as WebKitLegacyDeviceOrientationEvent ;
event . initDeviceOrientationEvent ( type , bubbles , cancelable , alpha , beta , gamma , absolute ) ;
}
break ;
2023-11-13 17:58:46 +01:00
case 'devicemotion' :
try {
event = new DeviceMotionEvent ( type , eventInit ) ;
} catch {
const { bubbles , cancelable , acceleration , accelerationIncludingGravity , rotationRate , interval } = eventInit as { bubbles : boolean , cancelable : boolean , acceleration : DeviceMotionEventAcceleration , accelerationIncludingGravity : DeviceMotionEventAcceleration , rotationRate : DeviceMotionEventRotationRate , interval : number } ;
event = this . document . createEvent ( 'DeviceMotionEvent' ) as WebKitLegacyDeviceMotionEvent ;
event . initDeviceMotionEvent ( type , bubbles , cancelable , acceleration , accelerationIncludingGravity , rotationRate , interval ) ;
}
break ;
2020-04-23 23:58:37 +02:00
default : event = new Event ( type , eventInit ) ; break ;
}
node . dispatchEvent ( event ) ;
}
2020-06-12 20:10:18 +02:00
previewNode ( node : Node ) : string {
if ( node . nodeType === Node . TEXT_NODE )
return oneLine ( ` #text= ${ node . nodeValue || '' } ` ) ;
if ( node . nodeType !== Node . ELEMENT_NODE )
return oneLine ( ` < ${ node . nodeName . toLowerCase ( ) } /> ` ) ;
const element = node as Element ;
2020-06-07 05:59:06 +02:00
const attrs = [ ] ;
for ( let i = 0 ; i < element . attributes . length ; i ++ ) {
2020-06-12 20:10:18 +02:00
const { name , value } = element . attributes [ i ] ;
2023-03-30 08:17:17 +02:00
if ( name === 'style' )
2020-06-12 20:10:18 +02:00
continue ;
if ( ! value && booleanAttributes . has ( name ) )
attrs . push ( ` ${ name } ` ) ;
else
attrs . push ( ` ${ name } =" ${ value } " ` ) ;
2020-06-07 05:59:06 +02:00
}
attrs . sort ( ( a , b ) = > a . length - b . length ) ;
2024-05-31 19:45:56 +02:00
const attrText = trimStringWithEllipsis ( attrs . join ( '' ) , 500 ) ;
2020-06-07 05:59:06 +02:00
if ( autoClosingTags . has ( element . nodeName ) )
2020-06-12 20:10:18 +02:00
return oneLine ( ` < ${ element . nodeName . toLowerCase ( ) } ${ attrText } /> ` ) ;
2020-06-07 05:59:06 +02:00
const children = element . childNodes ;
let onlyText = false ;
if ( children . length <= 5 ) {
onlyText = true ;
for ( let i = 0 ; i < children . length ; i ++ )
onlyText = onlyText && children [ i ] . nodeType === Node . TEXT_NODE ;
}
2023-11-09 00:11:01 +01:00
const text = onlyText ? ( element . textContent || '' ) : ( children . length ? '\u2026' : '' ) ;
return oneLine ( ` < ${ element . nodeName . toLowerCase ( ) } ${ attrText } > ${ trimStringWithEllipsis ( text , 50 ) } </ ${ element . nodeName . toLowerCase ( ) } > ` ) ;
2020-06-02 00:48:23 +02:00
}
2021-08-25 23:51:03 +02:00
2021-08-27 06:21:19 +02:00
strictModeViolationError ( selector : ParsedSelector , matches : Element [ ] ) : Error {
2021-08-25 23:51:03 +02:00
const infos = matches . slice ( 0 , 10 ) . map ( m = > ( {
preview : this.previewNode ( m ) ,
2024-01-23 20:29:40 +01:00
selector : this.generateSelectorSimple ( m ) ,
2021-08-25 23:51:03 +02:00
} ) ) ;
2022-11-04 23:19:16 +01:00
const lines = infos . map ( ( info , i ) = > ` \ n ${ i + 1 } ) ${ info . preview } aka ${ asLocator ( this . _sdkLanguage , info . selector ) } ` ) ;
2021-08-25 23:51:03 +02:00
if ( infos . length < matches . length )
lines . push ( '\n ...' ) ;
2022-11-04 23:19:16 +01:00
return this . createStacklessError ( ` strict mode violation: ${ asLocator ( this . _sdkLanguage , stringifySelector ( selector ) ) } resolved to ${ matches . length } elements: ${ lines . join ( '' ) } \ n ` ) ;
2021-08-27 06:21:19 +02:00
}
createStacklessError ( message : string ) : Error {
2021-09-25 05:51:09 +02:00
if ( this . _browserName === 'firefox' ) {
const error = new Error ( 'Error: ' + message ) ;
// Firefox cannot delete the stack, so assign to an empty string.
error . stack = '' ;
return error ;
}
2021-08-27 06:21:19 +02:00
const error = new Error ( message ) ;
2021-09-25 05:51:09 +02:00
// Chromium/WebKit should delete the stack instead.
2021-08-27 06:21:19 +02:00
delete error . stack ;
return error ;
2021-08-25 23:51:03 +02:00
}
2021-09-24 01:46:46 +02:00
2024-01-23 20:29:40 +01:00
createHighlight() {
return new Highlight ( this ) ;
}
2023-12-01 02:42:45 +01:00
maskSelectors ( selectors : ParsedSelector [ ] , color : string ) {
2022-02-15 16:05:05 +01:00
if ( this . _highlight )
this . hideHighlight ( ) ;
2022-08-10 01:42:55 +02:00
this . _highlight = new Highlight ( this ) ;
2022-02-15 16:05:05 +01:00
this . _highlight . install ( ) ;
const elements = [ ] ;
for ( const selector of selectors )
2023-02-17 20:19:53 +01:00
elements . push ( this . querySelectorAll ( selector , this . document . documentElement ) ) ;
2023-05-23 03:44:44 +02:00
this . _highlight . maskElements ( elements . flat ( ) , color ) ;
2022-02-15 16:05:05 +01:00
}
2022-01-12 16:37:48 +01:00
highlight ( selector : ParsedSelector ) {
if ( ! this . _highlight ) {
2022-08-10 01:42:55 +02:00
this . _highlight = new Highlight ( this ) ;
2022-01-12 16:37:48 +01:00
this . _highlight . install ( ) ;
}
2022-08-10 01:42:55 +02:00
this . _highlight . runHighlightOnRaf ( selector ) ;
2022-01-12 16:37:48 +01:00
}
hideHighlight() {
if ( this . _highlight ) {
this . _highlight . uninstall ( ) ;
delete this . _highlight ;
}
}
2023-03-16 06:33:40 +01:00
markTargetElements ( markedElements : Set < Element > , callId : string ) {
2024-10-03 08:48:26 +02:00
if ( this . _markedElements ? . callId !== callId )
this . _markedElements = undefined ;
const previous = this . _markedElements ? . elements || new Set ( ) ;
const unmarkEvent = new CustomEvent ( '__playwright_unmark_target__' , {
bubbles : true ,
cancelable : true ,
detail : callId ,
composed : true ,
} ) ;
for ( const element of previous ) {
if ( ! markedElements . has ( element ) )
element . dispatchEvent ( unmarkEvent ) ;
}
const markEvent = new CustomEvent ( '__playwright_mark_target__' , {
2023-03-30 08:17:17 +02:00
bubbles : true ,
cancelable : true ,
detail : callId ,
2023-08-31 21:46:49 +02:00
composed : true ,
2023-03-30 08:17:17 +02:00
} ) ;
2024-10-03 08:48:26 +02:00
for ( const element of markedElements ) {
if ( ! previous . has ( element ) )
element . dispatchEvent ( markEvent ) ;
}
this . _markedElements = { callId , elements : markedElements } ;
2022-12-21 02:26:54 +01:00
}
2021-10-31 01:06:52 +02:00
private _setupGlobalListenersRemovalDetection() {
const customEventName = '__playwright_global_listeners_check__' ;
let seenEvent = false ;
const handleCustomEvent = ( ) = > seenEvent = true ;
2023-02-17 20:19:53 +01:00
this . window . addEventListener ( customEventName , handleCustomEvent ) ;
2021-10-31 01:06:52 +02:00
new MutationObserver ( entries = > {
2023-02-17 20:19:53 +01:00
const newDocumentElement = entries . some ( entry = > Array . from ( entry . addedNodes ) . includes ( this . document . documentElement ) ) ;
2021-10-31 01:06:52 +02:00
if ( ! newDocumentElement )
return ;
// New documentElement - let's check whether listeners are still here.
seenEvent = false ;
2023-02-17 20:19:53 +01:00
this . window . dispatchEvent ( new CustomEvent ( customEventName ) ) ;
2021-10-31 01:06:52 +02:00
if ( seenEvent )
return ;
// Listener did not fire. Reattach the listener and notify.
2023-02-17 20:19:53 +01:00
this . window . addEventListener ( customEventName , handleCustomEvent ) ;
2021-10-31 01:06:52 +02:00
for ( const callback of this . onGlobalListenersRemoved )
callback ( ) ;
2023-02-17 20:19:53 +01:00
} ) . observe ( this . document , { childList : true } ) ;
2021-10-31 01:06:52 +02:00
}
2021-11-06 01:31:28 +01:00
private _setupHitTargetInterceptors() {
const listener = ( event : PointerEvent | MouseEvent | TouchEvent ) = > this . _hitTargetInterceptor ? . ( event ) ;
const addHitTargetInterceptorListeners = ( ) = > {
for ( const event of kAllHitTargetInterceptorEvents )
2023-02-17 20:19:53 +01:00
this . window . addEventListener ( event as any , listener , { capture : true , passive : false } ) ;
2021-11-06 01:31:28 +01:00
} ;
addHitTargetInterceptorListeners ( ) ;
this . onGlobalListenersRemoved . add ( addHitTargetInterceptorListeners ) ;
}
2024-04-10 10:01:19 +02:00
async expect ( element : Element | undefined , options : FrameExpectParams , elements : Element [ ] ) : Promise < { matches : boolean , received? : any , missingReceived? : boolean } > {
2022-12-21 02:26:54 +01:00
const isArray = options . expression === 'to.have.count' || options . expression . endsWith ( '.array' ) ;
2022-12-22 00:31:08 +01:00
if ( isArray )
return this . expectArray ( elements , options ) ;
if ( ! element ) {
// expect(locator).toBeHidden() passes when there is no element.
if ( ! options . isNot && options . expression === 'to.be.hidden' )
return { matches : true } ;
// expect(locator).not.toBeVisible() passes when there is no element.
if ( options . isNot && options . expression === 'to.be.visible' )
return { matches : false } ;
2023-03-29 20:09:17 +02:00
// expect(locator).toBeAttached({ attached: false }) passes when there is no element.
if ( ! options . isNot && options . expression === 'to.be.detached' )
return { matches : true } ;
// expect(locator).not.toBeAttached() passes when there is no element.
if ( options . isNot && options . expression === 'to.be.attached' )
return { matches : false } ;
2023-02-10 13:33:22 +01:00
// expect(locator).not.toBeInViewport() passes when there is no element.
if ( options . isNot && options . expression === 'to.be.in.viewport' )
return { matches : false } ;
2022-12-22 00:31:08 +01:00
// When none of the above applies, expect does not match.
2024-04-10 10:01:19 +02:00
return { matches : options.isNot , missingReceived : true } ;
2022-12-21 02:26:54 +01:00
}
2023-02-10 13:33:22 +01:00
return await this . expectSingleElement ( element , options ) ;
2022-12-21 02:26:54 +01:00
}
2023-02-10 13:33:22 +01:00
private async expectSingleElement ( element : Element , options : FrameExpectParams ) : Promise < { matches : boolean , received? : any } > {
2021-09-24 20:06:30 +02:00
const expression = options . expression ;
{
// Element state / boolean values.
2025-01-08 00:31:18 +01:00
let result : ElementStateQueryResult | undefined ;
2023-10-04 18:27:28 +02:00
if ( expression === 'to.have.attribute' ) {
2025-01-08 00:31:18 +01:00
const hasAttribute = element . hasAttribute ( options . expressionArg ) ;
result = {
matches : hasAttribute ,
received : hasAttribute ? 'attribute present' : 'attribute not present' ,
} ;
2023-10-04 18:27:28 +02:00
} else if ( expression === 'to.be.checked' ) {
2025-01-08 00:31:18 +01:00
result = this . elementState ( element , 'checked' ) ;
2021-12-02 19:31:26 +01:00
} else if ( expression === 'to.be.unchecked' ) {
2025-01-08 00:31:18 +01:00
result = this . elementState ( element , 'unchecked' ) ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.be.disabled' ) {
2025-01-08 00:31:18 +01:00
result = this . elementState ( element , 'disabled' ) ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.be.editable' ) {
2025-01-08 00:31:18 +01:00
result = this . elementState ( element , 'editable' ) ;
2022-09-06 21:50:45 +02:00
} else if ( expression === 'to.be.readonly' ) {
2025-01-08 00:31:18 +01:00
result = this . elementState ( element , 'editable' ) ;
result . matches = ! result . matches ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.be.empty' ) {
2025-01-08 00:31:18 +01:00
if ( element . nodeName === 'INPUT' || element . nodeName === 'TEXTAREA' ) {
const value = ( element as HTMLInputElement ) . value ;
result = { matches : ! value , received : value ? 'notEmpty' : 'empty' } ;
} else {
const text = element . textContent ? . trim ( ) ;
result = { matches : ! text , received : text ? 'notEmpty' : 'empty' } ;
}
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.be.enabled' ) {
2025-01-08 00:31:18 +01:00
result = this . elementState ( element , 'enabled' ) ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.be.focused' ) {
2025-01-08 00:31:18 +01:00
const focused = this . _activelyFocused ( element ) . isFocused ;
result = {
matches : focused ,
received : focused ? 'focused' : 'inactive' ,
} ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.be.hidden' ) {
2025-01-08 00:31:18 +01:00
result = this . elementState ( element , 'hidden' ) ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.be.visible' ) {
2025-01-08 00:31:18 +01:00
result = this . elementState ( element , 'visible' ) ;
2023-03-29 20:09:17 +02:00
} else if ( expression === 'to.be.attached' ) {
2025-01-08 00:31:18 +01:00
result = {
matches : true ,
received : 'attached' ,
} ;
2023-03-29 20:09:17 +02:00
} else if ( expression === 'to.be.detached' ) {
2025-01-08 00:31:18 +01:00
result = {
matches : false ,
received : 'attached' ,
} ;
2021-09-24 20:06:30 +02:00
}
2025-01-08 00:31:18 +01:00
if ( result ) {
if ( result . received === 'error:notconnected' )
2022-12-21 02:26:54 +01:00
throw this . createStacklessError ( 'Element is not connected' ) ;
2025-01-08 00:31:18 +01:00
return result ;
2021-09-24 20:06:30 +02:00
}
}
{
// JS property
if ( expression === 'to.have.property' ) {
2023-10-07 00:47:07 +02:00
let target = element ;
const properties = options . expressionArg . split ( '.' ) ;
for ( let i = 0 ; i < properties . length - 1 ; i ++ ) {
if ( typeof target !== 'object' || ! ( properties [ i ] in target ) )
return { received : undefined , matches : false } ;
target = ( target as any ) [ properties [ i ] ] ;
}
const received = ( target as any ) [ properties [ properties . length - 1 ] ] ;
2021-09-24 20:06:30 +02:00
const matches = deepEquals ( received , options . expectedValue ) ;
2021-10-13 17:56:57 +02:00
return { received , matches } ;
2021-09-24 20:06:30 +02:00
}
}
2023-02-10 13:33:22 +01:00
{
// Viewport intersection
if ( expression === 'to.be.in.viewport' ) {
const ratio = await this . viewportRatio ( element ) ;
2023-02-16 20:02:19 +01:00
return { received : ` viewport ratio ${ ratio } ` , matches : ratio > 0 && ratio > ( options . expectedNumber ? ? 0 ) - 1 e - 9 } ;
2023-02-10 13:33:22 +01:00
}
}
2021-09-24 20:06:30 +02:00
2022-06-02 18:10:28 +02:00
// Multi-Select/Combobox
{
2022-06-02 22:01:34 +02:00
if ( expression === 'to.have.values' ) {
2022-06-02 18:10:28 +02:00
element = this . retarget ( element , 'follow-label' ) ! ;
if ( element . nodeName !== 'SELECT' || ! ( element as HTMLSelectElement ) . multiple )
throw this . createStacklessError ( 'Not a select element with a multiple attribute' ) ;
const received = [ . . . ( element as HTMLSelectElement ) . selectedOptions ] . map ( o = > o . value ) ;
2022-06-02 22:01:34 +02:00
if ( received . length !== options . expectedText ! . length )
2022-06-02 18:10:28 +02:00
return { received , matches : false } ;
return { received , matches : received.map ( ( r , i ) = > new ExpectedTextMatcher ( options . expectedText ! [ i ] ) . matches ( r ) ) . every ( Boolean ) } ;
}
}
2024-10-14 23:07:19 +02:00
{
2024-11-08 16:43:01 +01:00
if ( expression === 'to.match.aria' ) {
const result = matchesAriaTree ( element , options . expectedValue ) ;
return {
received : result.received ,
matches : ! ! result . matches . length ,
} ;
}
2024-10-14 23:07:19 +02:00
}
2021-09-24 20:06:30 +02:00
{
// Single text value.
let received : string | undefined ;
2023-10-04 18:27:28 +02:00
if ( expression === 'to.have.attribute.value' ) {
2022-09-21 02:11:12 +02:00
const value = element . getAttribute ( options . expressionArg ) ;
if ( value === null )
return { received : null , matches : false } ;
received = value ;
2022-07-14 22:03:37 +02:00
} else if ( expression === 'to.have.class' ) {
2022-06-30 16:01:26 +02:00
received = element . classList . toString ( ) ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.have.css' ) {
2023-02-17 20:19:53 +01:00
received = this . window . getComputedStyle ( element ) . getPropertyValue ( options . expressionArg ) ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.have.id' ) {
received = element . id ;
} else if ( expression === 'to.have.text' ) {
2022-08-11 23:10:12 +02:00
received = options . useInnerText ? ( element as HTMLElement ) . innerText : elementText ( new Map ( ) , element ) . full ;
2024-04-18 21:28:55 +02:00
} else if ( expression === 'to.have.accessible.name' ) {
received = getElementAccessibleName ( element , false /* includeHidden */ ) ;
2024-04-22 21:33:30 +02:00
} else if ( expression === 'to.have.accessible.description' ) {
received = getElementAccessibleDescription ( element , false /* includeHidden */ ) ;
2024-12-27 10:54:16 +01:00
} else if ( expression === 'to.have.accessible.error.message' ) {
received = getElementAccessibleErrorMessage ( element ) ;
2024-04-26 00:26:10 +02:00
} else if ( expression === 'to.have.role' ) {
received = getAriaRole ( element ) || '' ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.have.title' ) {
2023-02-17 20:19:53 +01:00
received = this . document . title ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.have.url' ) {
2023-02-17 20:19:53 +01:00
received = this . document . location . href ;
2021-09-24 20:06:30 +02:00
} else if ( expression === 'to.have.value' ) {
2021-12-02 19:31:06 +01:00
element = this . retarget ( element , 'follow-label' ) ! ;
2021-09-24 20:06:30 +02:00
if ( element . nodeName !== 'INPUT' && element . nodeName !== 'TEXTAREA' && element . nodeName !== 'SELECT' )
throw this . createStacklessError ( 'Not an input element' ) ;
2022-06-02 18:10:28 +02:00
received = ( element as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement ) . value ;
2021-09-24 20:06:30 +02:00
}
if ( received !== undefined && options . expectedText ) {
const matcher = new ExpectedTextMatcher ( options . expectedText [ 0 ] ) ;
2022-07-14 22:03:37 +02:00
return { received , matches : matcher.matches ( received ) } ;
2021-09-24 20:06:30 +02:00
}
}
2021-11-02 00:42:13 +01:00
throw this . createStacklessError ( 'Unknown expect matcher: ' + expression ) ;
}
2022-12-21 02:26:54 +01:00
private expectArray ( elements : Element [ ] , options : FrameExpectParams ) : { matches : boolean , received? : any } {
2021-11-02 00:42:13 +01:00
const expression = options . expression ;
if ( expression === 'to.have.count' ) {
const received = elements . length ;
const matches = received === options . expectedNumber ;
return { received , matches } ;
}
// List of values.
let received : string [ ] | undefined ;
if ( expression === 'to.have.text.array' || expression === 'to.contain.text.array' )
2022-08-11 23:10:12 +02:00
received = elements . map ( e = > options . useInnerText ? ( e as HTMLElement ) . innerText : elementText ( new Map ( ) , e ) . full ) ;
2022-07-14 22:03:37 +02:00
else if ( expression === 'to.have.class.array' )
2022-06-30 16:01:26 +02:00
received = elements . map ( e = > e . classList . toString ( ) ) ;
2024-11-18 16:46:47 +01:00
else if ( expression === 'to.have.accessible.name.array' )
received = elements . map ( e = > getElementAccessibleName ( e , false ) ) ;
2021-11-02 00:42:13 +01:00
if ( received && options . expectedText ) {
// "To match an array" is "to contain an array" + "equal length"
const lengthShouldMatch = expression !== 'to.contain.text.array' ;
const matchesLength = received . length === options . expectedText . length || ! lengthShouldMatch ;
if ( ! matchesLength )
return { received , matches : false } ;
// Each matcher should get a "received" that matches it, in order.
const matchers = options . expectedText . map ( e = > new ExpectedTextMatcher ( e ) ) ;
2022-07-12 20:03:27 +02:00
let mIndex = 0 , rIndex = 0 ;
while ( mIndex < matchers . length && rIndex < received . length ) {
2022-07-14 22:03:37 +02:00
if ( matchers [ mIndex ] . matches ( received [ rIndex ] ) )
2022-07-12 20:03:27 +02:00
++ mIndex ;
++ rIndex ;
2021-09-24 20:06:30 +02:00
}
2022-07-12 20:03:27 +02:00
return { received , matches : mIndex === matchers . length } ;
2021-09-24 20:06:30 +02:00
}
2021-11-02 00:42:13 +01:00
throw this . createStacklessError ( 'Unknown expect matcher: ' + expression ) ;
2021-09-24 01:46:46 +02:00
}
2019-11-21 23:43:30 +01:00
}
2020-04-23 23:58:37 +02:00
2020-06-07 05:59:06 +02:00
const autoClosingTags = new Set ( [ 'AREA' , 'BASE' , 'BR' , 'COL' , 'COMMAND' , 'EMBED' , 'HR' , 'IMG' , 'INPUT' , 'KEYGEN' , 'LINK' , 'MENUITEM' , 'META' , 'PARAM' , 'SOURCE' , 'TRACK' , 'WBR' ] ) ;
2020-06-12 20:10:18 +02:00
const booleanAttributes = new Set ( [ 'checked' , 'selected' , 'disabled' , 'readonly' , 'multiple' ] ) ;
function oneLine ( s : string ) : string {
return s . replace ( /\n/g , '↵' ) . replace ( /\t/g , '⇆' ) ;
}
2020-06-07 05:59:06 +02:00
2023-11-13 17:58:46 +01:00
const eventType = new Map < string , ' mouse ' | ' keyboard ' | ' touch ' | ' pointer ' | ' focus ' | ' drag ' | ' wheel ' | ' deviceorientation ' | ' devicemotion ' > ( [
2020-04-23 23:58:37 +02:00
[ 'auxclick' , 'mouse' ] ,
[ 'click' , 'mouse' ] ,
[ 'dblclick' , 'mouse' ] ,
2022-07-12 23:17:10 +02:00
[ 'mousedown' , 'mouse' ] ,
2020-04-23 23:58:37 +02:00
[ 'mouseeenter' , 'mouse' ] ,
[ 'mouseleave' , 'mouse' ] ,
[ 'mousemove' , 'mouse' ] ,
[ 'mouseout' , 'mouse' ] ,
[ 'mouseover' , 'mouse' ] ,
[ 'mouseup' , 'mouse' ] ,
[ 'mouseleave' , 'mouse' ] ,
[ 'mousewheel' , 'mouse' ] ,
[ 'keydown' , 'keyboard' ] ,
[ 'keyup' , 'keyboard' ] ,
[ 'keypress' , 'keyboard' ] ,
[ 'textInput' , 'keyboard' ] ,
[ 'touchstart' , 'touch' ] ,
[ 'touchmove' , 'touch' ] ,
[ 'touchend' , 'touch' ] ,
[ 'touchcancel' , 'touch' ] ,
[ 'pointerover' , 'pointer' ] ,
[ 'pointerout' , 'pointer' ] ,
[ 'pointerenter' , 'pointer' ] ,
[ 'pointerleave' , 'pointer' ] ,
[ 'pointerdown' , 'pointer' ] ,
[ 'pointerup' , 'pointer' ] ,
[ 'pointermove' , 'pointer' ] ,
[ 'pointercancel' , 'pointer' ] ,
[ 'gotpointercapture' , 'pointer' ] ,
[ 'lostpointercapture' , 'pointer' ] ,
[ 'focus' , 'focus' ] ,
[ 'blur' , 'focus' ] ,
[ 'drag' , 'drag' ] ,
[ 'dragstart' , 'drag' ] ,
[ 'dragend' , 'drag' ] ,
[ 'dragover' , 'drag' ] ,
[ 'dragenter' , 'drag' ] ,
[ 'dragleave' , 'drag' ] ,
[ 'dragexit' , 'drag' ] ,
[ 'drop' , 'drag' ] ,
2022-07-13 02:17:45 +02:00
[ 'wheel' , 'wheel' ] ,
2023-11-08 18:50:25 +01:00
[ 'deviceorientation' , 'deviceorientation' ] ,
[ 'deviceorientationabsolute' , 'deviceorientation' ] ,
2023-11-13 17:58:46 +01:00
[ 'devicemotion' , 'devicemotion' ] ,
2020-04-23 23:58:37 +02:00
] ) ;
2020-08-25 00:30:45 +02:00
2021-11-06 01:31:28 +01:00
const kHoverHitTargetInterceptorEvents = new Set ( [ 'mousemove' ] ) ;
const kTapHitTargetInterceptorEvents = new Set ( [ 'pointerdown' , 'pointerup' , 'touchstart' , 'touchend' , 'touchcancel' ] ) ;
const kMouseHitTargetInterceptorEvents = new Set ( [ 'mousedown' , 'mouseup' , 'pointerdown' , 'pointerup' , 'click' , 'auxclick' , 'dblclick' , 'contextmenu' ] ) ;
const kAllHitTargetInterceptorEvents = new Set ( [ . . . kHoverHitTargetInterceptorEvents , . . . kTapHitTargetInterceptorEvents , . . . kMouseHitTargetInterceptorEvents ] ) ;
2022-10-18 22:09:54 +02:00
function cssUnquote ( s : string ) : string {
// Trim quotes.
s = s . substring ( 1 , s . length - 1 ) ;
2021-02-05 02:44:55 +01:00
if ( ! s . includes ( '\\' ) )
return s ;
const r : string [ ] = [ ] ;
let i = 0 ;
while ( i < s . length ) {
if ( s [ i ] === '\\' && i + 1 < s . length )
i ++ ;
r . push ( s [ i ++ ] ) ;
}
return r . join ( '' ) ;
}
2022-10-22 01:29:45 +02:00
function createTextMatcher ( selector : string , internal : boolean ) : { matcher : TextMatcher , kind : 'regex' | 'strict' | 'lax' } {
2021-02-05 02:44:55 +01:00
if ( selector [ 0 ] === '/' && selector . lastIndexOf ( '/' ) > 0 ) {
const lastSlash = selector . lastIndexOf ( '/' ) ;
2022-12-05 23:08:54 +01:00
const re = new RegExp ( selector . substring ( 1 , lastSlash ) , selector . substring ( lastSlash + 1 ) ) ;
return { matcher : ( elementText : ElementText ) = > re . test ( elementText . full ) , kind : 'regex' } ;
2021-02-05 02:44:55 +01:00
}
2022-10-18 22:09:54 +02:00
const unquote = internal ? JSON . parse . bind ( JSON ) : cssUnquote ;
2021-02-05 02:44:55 +01:00
let strict = false ;
if ( selector . length > 1 && selector [ 0 ] === '"' && selector [ selector . length - 1 ] === '"' ) {
2022-10-18 22:09:54 +02:00
selector = unquote ( selector ) ;
2021-02-05 02:44:55 +01:00
strict = true ;
2022-10-18 22:09:54 +02:00
} else if ( internal && selector . length > 1 && selector [ 0 ] === '"' && selector [ selector . length - 2 ] === '"' && selector [ selector . length - 1 ] === 'i' ) {
selector = unquote ( selector . substring ( 0 , selector . length - 1 ) ) ;
strict = false ;
} else if ( internal && selector . length > 1 && selector [ 0 ] === '"' && selector [ selector . length - 2 ] === '"' && selector [ selector . length - 1 ] === 's' ) {
selector = unquote ( selector . substring ( 0 , selector . length - 1 ) ) ;
strict = true ;
} else if ( selector . length > 1 && selector [ 0 ] === "'" && selector [ selector . length - 1 ] === "'" ) {
selector = unquote ( selector ) ;
2021-02-05 02:44:55 +01:00
strict = true ;
}
2022-12-05 23:08:54 +01:00
selector = normalizeWhiteSpace ( selector ) ;
if ( strict ) {
if ( internal )
2024-01-23 06:33:56 +01:00
return { kind : 'strict' , matcher : ( elementText : ElementText ) = > elementText . normalized === selector } ;
2022-12-05 23:08:54 +01:00
const strictTextNodeMatcher = ( elementText : ElementText ) = > {
if ( ! selector && ! elementText . immediate . length )
return true ;
return elementText . immediate . some ( s = > normalizeWhiteSpace ( s ) === selector ) ;
} ;
return { matcher : strictTextNodeMatcher , kind : 'strict' } ;
}
selector = selector . toLowerCase ( ) ;
2024-01-23 06:33:56 +01:00
return { kind : 'lax' , matcher : ( elementText : ElementText ) = > elementText . normalized . toLowerCase ( ) . includes ( selector ) } ;
2021-02-05 02:44:55 +01:00
}
2021-09-24 01:46:46 +02:00
class ExpectedTextMatcher {
_string : string | undefined ;
private _substring : string | undefined ;
private _regex : RegExp | undefined ;
private _normalizeWhiteSpace : boolean | undefined ;
2022-06-02 14:52:53 +02:00
private _ignoreCase : boolean | undefined ;
2021-09-24 01:46:46 +02:00
2021-09-24 20:06:30 +02:00
constructor ( expected : channels.ExpectedTextValue ) {
2021-09-24 01:46:46 +02:00
this . _normalizeWhiteSpace = expected . normalizeWhiteSpace ;
2022-06-02 14:52:53 +02:00
this . _ignoreCase = expected . ignoreCase ;
this . _string = expected . matchSubstring ? undefined : this . normalize ( expected . string ) ;
this . _substring = expected . matchSubstring ? this . normalize ( expected . string ) : undefined ;
if ( expected . regexSource ) {
const flags = new Set ( ( expected . regexFlags || '' ) . split ( '' ) ) ;
if ( expected . ignoreCase === false )
flags . delete ( 'i' ) ;
if ( expected . ignoreCase === true )
flags . add ( 'i' ) ;
this . _regex = new RegExp ( expected . regexSource , [ . . . flags ] . join ( '' ) ) ;
}
2021-09-24 01:46:46 +02:00
}
2022-07-14 22:03:37 +02:00
matches ( text : string ) : boolean {
2022-06-02 14:52:53 +02:00
if ( ! this . _regex )
text = this . normalize ( text ) ! ;
2021-09-24 01:46:46 +02:00
if ( this . _string !== undefined )
return text === this . _string ;
if ( this . _substring !== undefined )
return text . includes ( this . _substring ) ;
if ( this . _regex )
return ! ! this . _regex . test ( text ) ;
return false ;
}
2022-06-02 14:52:53 +02:00
private normalize ( s : string | undefined ) : string | undefined {
2021-09-24 01:46:46 +02:00
if ( ! s )
return s ;
2022-06-02 14:52:53 +02:00
if ( this . _normalizeWhiteSpace )
2022-12-05 23:08:54 +01:00
s = normalizeWhiteSpace ( s ) ;
2022-06-02 14:52:53 +02:00
if ( this . _ignoreCase )
s = s . toLocaleLowerCase ( ) ;
return s ;
2021-09-24 01:46:46 +02:00
}
}
2021-09-24 20:06:30 +02:00
function deepEquals ( a : any , b : any ) : boolean {
if ( a === b )
return true ;
if ( a && b && typeof a === 'object' && typeof b === 'object' ) {
if ( a . constructor !== b . constructor )
return false ;
if ( Array . isArray ( a ) ) {
if ( a . length !== b . length )
return false ;
for ( let i = 0 ; i < a . length ; ++ i ) {
if ( ! deepEquals ( a [ i ] , b [ i ] ) )
return false ;
}
return true ;
}
if ( a instanceof RegExp )
return a . source === b . source && a . flags === b . flags ;
// This covers Date.
if ( a . valueOf !== Object . prototype . valueOf )
return a . valueOf ( ) === b . valueOf ( ) ;
// This covers custom objects.
if ( a . toString !== Object . prototype . toString )
return a . toString ( ) === b . toString ( ) ;
const keys = Object . keys ( a ) ;
if ( keys . length !== Object . keys ( b ) . length )
return false ;
for ( let i = 0 ; i < keys . length ; ++ i ) {
if ( ! b . hasOwnProperty ( keys [ i ] ) )
return false ;
}
for ( const key of keys ) {
if ( ! deepEquals ( a [ key ] , b [ key ] ) )
return false ;
}
return true ;
}
2021-10-29 03:45:59 +02:00
if ( typeof a === 'number' && typeof b === 'number' )
return isNaN ( a ) && isNaN ( b ) ;
return false ;
2021-09-24 20:06:30 +02:00
}
2024-05-31 23:44:26 +02:00
declare global {
interface Window {
2024-06-07 00:56:13 +02:00
__pwClock ? : {
2024-05-31 23:44:26 +02:00
builtin : {
setTimeout : Window [ 'setTimeout' ] ,
2024-10-23 19:24:53 +02:00
clearTimeout : Window [ 'clearTimeout' ] ,
2024-05-31 23:44:26 +02:00
requestAnimationFrame : Window [ 'requestAnimationFrame' ] ,
}
}
}
}