/** * 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. */ import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot'; import { asLocator } from '@isomorphic/locatorGenerators'; import { parseAttributeSelector, parseSelector, stringifySelector, visitAllSelectorParts } from '@isomorphic/selectorParser'; import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '@isomorphic/stringUtils'; import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTree } from './ariaSnapshot'; import { beginDOMCaches, enclosingShadowRootOrDocument, endDOMCaches, isElementVisible, isInsideScope, parentElementOrShadowHost, setGlobalOptions } from './domUtils'; import { Highlight } from './highlight'; import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils'; import { createReactEngine } from './reactSelectorEngine'; import { createRoleEngine } from './roleSelectorEngine'; import { beginAriaCaches, endAriaCaches, getAriaDisabled, getAriaRole, getCheckedAllowMixed, getCheckedWithoutMixed, getElementAccessibleDescription, getElementAccessibleErrorMessage, getElementAccessibleName, getReadonly } from './roleUtils'; import { SelectorEvaluatorImpl, sortInDOMOrder } from './selectorEvaluator'; import { generateSelector } from './selectorGenerator'; import { elementMatchesText, elementText, getElementLabels } from './selectorUtils'; import { createVueEngine } from './vueSelectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; import { ConsoleAPI } from './consoleApi'; import { UtilityScript } from './utilityScript'; import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot'; import type { CSSComplexSelectorList } from '@isomorphic/cssParser'; import type { Language } from '@isomorphic/locatorGenerators'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '@isomorphic/selectorParser'; import type * as channels from '@protocol/channels'; import type { AriaSnapshot, AriaTreeOptions } from './ariaSnapshot'; import type { LayoutSelectorName } from './layoutSelectorUtils'; import type { SelectorEngine, SelectorRoot } from './selectorEngine'; import type { GenerateSelectorOptions } from './selectorGenerator'; import type { ElementText, TextMatcher } from './selectorUtils'; import type { Builtins } from './utilityScript'; export type FrameExpectParams = Omit & { expectedValue?: any }; export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'indeterminate' | 'stable'; export type ElementStateWithoutStable = Exclude; export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected', isRadio?: boolean }; export type HitTargetInterceptionResult = { stop: () => 'done' | { hitTargetDescription: string }; }; interface WebKitLegacyDeviceOrientationEvent extends DeviceOrientationEvent { readonly initDeviceOrientationEvent: (type: string, bubbles: boolean, cancelable: boolean, alpha: number, beta: number, gamma: number, absolute: boolean) => void; } interface WebKitLegacyDeviceMotionEvent extends DeviceMotionEvent { readonly initDeviceMotionEvent: (type: string, bubbles: boolean, cancelable: boolean, acceleration: DeviceMotionEventAcceleration, accelerationIncludingGravity: DeviceMotionEventAcceleration, rotationRate: DeviceMotionEventRotationRate, interval: number) => void; } export type InjectedScriptOptions = { isUnderTest: boolean; sdkLanguage: Language; // For strict error and codegen testIdAttributeName: string; stableRafCount: number; browserName: string; isUtilityWorld?: boolean; customEngines: { name: string, source: string }[]; }; export class InjectedScript { private _engines: Map; readonly _evaluator: SelectorEvaluatorImpl; private _stableRafCount: number; private _browserName: string; private _isUtilityWorld: boolean; readonly onGlobalListenersRemoved: Set<() => void>; private _hitTargetInterceptor: undefined | ((event: MouseEvent | PointerEvent | TouchEvent) => void); private _highlight: Highlight | undefined; readonly isUnderTest: boolean; private _sdkLanguage: Language; private _testIdAttributeNameForStrictErrorAndConsoleCodegen: string = 'data-testid'; private _markedElements?: { callId: string, elements: Set }; readonly window: Window & typeof globalThis; readonly document: Document; readonly consoleApi: ConsoleAPI; private _lastAriaSnapshot: AriaSnapshot | undefined; // 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, parseAriaSnapshot, generateAriaTree, // Builtins protect injected code from clock emulation. builtins: null as unknown as Builtins, }; private _autoClosingTags: Set; private _booleanAttributes: Set; private _eventTypes: Map; private _hoverHitTargetInterceptorEvents: Set; private _tapHitTargetInterceptorEvents: Set; private _mouseHitTargetInterceptorEvents: Set; private _allHitTargetInterceptorEvents: Set; constructor(window: Window & typeof globalThis, options: InjectedScriptOptions) { this.window = window; this.document = window.document; this.isUnderTest = options.isUnderTest; // Make sure builtins are created from "window". This is important for InjectedScript instantiated // inside a trace viewer snapshot, where "window" differs from "globalThis". this.utils.builtins = new UtilityScript(window, options.isUnderTest).builtins; this._sdkLanguage = options.sdkLanguage; this._testIdAttributeNameForStrictErrorAndConsoleCodegen = options.testIdAttributeName; this._evaluator = new SelectorEvaluatorImpl(); this.consoleApi = new ConsoleAPI(this); this.onGlobalListenersRemoved = new Set(); this._autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); this._booleanAttributes = new Set(['checked', 'selected', 'disabled', 'readonly', 'multiple']); this._eventTypes = new Map([ ['auxclick', 'mouse'], ['click', 'mouse'], ['dblclick', 'mouse'], ['mousedown', 'mouse'], ['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'], ['wheel', 'wheel'], ['deviceorientation', 'deviceorientation'], ['deviceorientationabsolute', 'deviceorientation'], ['devicemotion', 'devicemotion'], ]); this._hoverHitTargetInterceptorEvents = new Set(['mousemove']); this._tapHitTargetInterceptorEvents = new Set(['pointerdown', 'pointerup', 'touchstart', 'touchend', 'touchcancel']); this._mouseHitTargetInterceptorEvents = new Set(['mousedown', 'mouseup', 'pointerdown', 'pointerup', 'click', 'auxclick', 'dblclick', 'contextmenu']); this._allHitTargetInterceptorEvents = new Set([...this._hoverHitTargetInterceptorEvents, ...this._tapHitTargetInterceptorEvents, ...this._mouseHitTargetInterceptorEvents]); this._engines = new Map(); this._engines.set('xpath', XPathEngine); this._engines.set('xpath:light', XPathEngine); this._engines.set('_react', createReactEngine()); this._engines.set('_vue', createVueEngine()); this._engines.set('role', createRoleEngine(false)); this._engines.set('text', this._createTextEngine(true, false)); this._engines.set('text:light', this._createTextEngine(false, false)); 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()); this._engines.set('nth', { queryAll: () => [] }); this._engines.set('visible', this._createVisibleEngine()); this._engines.set('internal:control', this._createControlEngine()); this._engines.set('internal:has', this._createHasEngine()); this._engines.set('internal:has-not', this._createHasNotEngine()); this._engines.set('internal:and', { queryAll: () => [] }); this._engines.set('internal:or', { queryAll: () => [] }); this._engines.set('internal:chain', this._createInternalChainEngine()); this._engines.set('internal:label', this._createInternalLabelEngine()); this._engines.set('internal:text', this._createTextEngine(true, true)); this._engines.set('internal:has-text', this._createInternalHasTextEngine()); this._engines.set('internal:has-not-text', this._createInternalHasNotTextEngine()); this._engines.set('internal:attr', this._createNamedAttributeEngine()); this._engines.set('internal:testid', this._createNamedAttributeEngine()); this._engines.set('internal:role', createRoleEngine(true)); this._engines.set('internal:describe', this._createDescribeEngine()); this._engines.set('aria-ref', this._createAriaRefEngine()); for (const { name, source } of options.customEngines) this._engines.set(name, this.eval(source)); this._stableRafCount = options.stableRafCount; this._browserName = options.browserName; this._isUtilityWorld = !!options.isUtilityWorld; setGlobalOptions({ browserNameForWorkarounds: options.browserName }); this._setupGlobalListenersRemovalDetection(); this._setupHitTargetInterceptors(); if (this.isUnderTest) (this.window as any).__injectedScript = this; } eval(expression: string): any { return this.window.eval(expression); } testIdAttributeNameForStrictErrorAndConsoleCodegen(): string { return this._testIdAttributeNameForStrictErrorAndConsoleCodegen; } parseSelector(selector: string): ParsedSelector { const result = parseSelector(selector); visitAllSelectorParts(result, part => { if (!this._engines.has(part.name)) throw this.createStacklessError(`Unknown engine "${part.name}" while parsing selector ${selector}`); }); return result; } generateSelector(targetElement: Element, options: GenerateSelectorOptions) { return generateSelector(this, targetElement, options); } generateSelectorSimple(targetElement: Element, options?: GenerateSelectorOptions): string { return generateSelector(this, targetElement, { ...options, testIdAttributeName: this._testIdAttributeNameForStrictErrorAndConsoleCodegen }).selector; } querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined { const result = this.querySelectorAll(selector, root); if (strict && result.length > 1) throw this.strictModeViolationError(selector, result); return result[0]; } private _queryNth(elements: Set, part: ParsedSelectorPart): Set { const list = [...elements]; let nth = +part.body; if (nth === -1) nth = list.length - 1; return new Set(list.slice(nth, nth + 1)); } private _queryLayoutSelector(elements: Set, part: ParsedSelectorPart, originalRoot: Node): Set { 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(result.map(r => r.element)); } ariaSnapshot(node: Node, options: AriaTreeOptions): string { if (node.nodeType !== Node.ELEMENT_NODE) throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); this._lastAriaSnapshot = generateAriaTree(node as Element, options); return renderAriaTree(this._lastAriaSnapshot, options); } ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map } { const tree = generateAriaTree(this.document.body, { mode: 'ai' }); const ariaSnapshot = renderAriaTree(tree, { mode: 'ai' }); return { ariaSnapshot, refs: tree.refs }; } getAllElementsMatchingExpectAriaTemplate(document: Document, template: AriaTemplateNode): Element[] { return getAllElementsMatchingExpectAriaTemplate(document.documentElement, template); } 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) { const parsed: ParsedSelector = { parts: selector.parts.slice(selector.capture + 1) }; const has: ParsedSelectorPart = { name: 'internal:has', body: { parsed }, source: stringifySelector(parsed) }; withHas.parts.push(has); } return this.querySelectorAll(withHas, root); } if (!(root as any)['querySelectorAll']) throw this.createStacklessError('Node is not queryable.'); 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.'); } // 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]; this._evaluator.begin(); try { let roots = new Set([root as Element]); for (const part of selector.parts) { if (part.name === 'nth') { roots = this._queryNth(roots, part); } 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))); } else if (part.name === 'internal:or') { const orElements = this.querySelectorAll((part.body as NestedSelectorBody).parsed, root); roots = new Set(sortInDOMOrder(new Set([...roots, ...orElements]))); } else if (kLayoutSelectorNames.includes(part.name as LayoutSelectorName)) { roots = this._queryLayoutSelector(roots, part, root); } else { const next = new Set(); for (const root of roots) { const all = this._queryEngineAll(part, root); for (const one of all) next.add(one); } roots = next; } } return [...roots]; } finally { this._evaluator.end(); } } private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] { 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; } 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 { queryAll: (root: SelectorRoot, selector: string): Element[] => { return this._evaluator.query({ scope: root as Document | Element, pierceShadow: shadow }, toCSS(selector)); } }; } private _createCSSEngine(): SelectorEngine { return { queryAll: (root: SelectorRoot, body: any) => { return this._evaluator.query({ scope: root as Document | Element, pierceShadow: true }, body); } }; } private _createTextEngine(shadow: boolean, internal: boolean): SelectorEngine { const queryAll = (root: SelectorRoot, selector: string): Element[] => { const { matcher, kind } = createTextMatcher(selector, internal); const result: Element[] = []; let lastDidNotMatchSelf: Element | null = null; const appendElement = (element: Element) => { // TODO: replace contains() with something shadow-dom-aware? if (kind === 'lax' && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element)) return false; const matches = elementMatchesText(this._evaluator._cacheText, element, matcher); if (matches === 'none') lastDidNotMatchSelf = element; if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict' && !internal)) result.push(element); }; if (root.nodeType === Node.ELEMENT_NODE) appendElement(root as Element); const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*'); for (const element of elements) appendElement(element); return result; }; return { queryAll }; } private _createInternalHasTextEngine(): 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] : []; } }; } 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]; } }; } private _createInternalLabelEngine(): SelectorEngine { return { queryAll: (root: SelectorRoot, selector: string): Element[] => { const { matcher } = createTextMatcher(selector, true); const allElements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, '*'); return allElements.filter(element => { return getElementLabels(this._evaluator._cacheText, element).some(label => matcher(label)); }); } }; } private _createNamedAttributeEngine(): SelectorEngine { const queryAll = (root: SelectorRoot, selector: string): Element[] => { 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)!)); }; return { queryAll }; } private _createDescribeEngine(): SelectorEngine { const queryAll = (root: SelectorRoot): Element[] => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; return [root as Element]; }; return { queryAll }; } private _createControlEngine(): SelectorEngine { return { queryAll(root: SelectorRoot, body: any) { if (body === 'enter-frame') return []; if (body === 'return-empty') return []; 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]; } throw new Error(`Internal error, unknown internal:control selector ${body}`); } }; } private _createHasEngine(): 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 }; } 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 }; } private _createVisibleEngine(): SelectorEngine { const queryAll = (root: SelectorRoot, body: string) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; const visible = body === 'true'; return isElementVisible(root as Element) === visible ? [root as Element] : []; }; return { queryAll }; } private _createInternalChainEngine(): SelectorEngine { const queryAll = (root: SelectorRoot, body: NestedSelectorBody) => { return this.querySelectorAll(body.parsed, root); }; return { queryAll }; } extend(source: string, params: any): any { const constrFunction = this.window.eval(` (() => { const module = {}; ${source} return module.exports.default(); })()`); return new constrFunction(this, params); } async viewportRatio(element: Element): Promise { 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. this.utils.builtins.requestAnimationFrame(() => {}); }); } 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) }; } describeIFrameStyle(iframe: Element): 'error:notconnected' | 'transformed' | { left: number, top: number } { 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); return { left: parseInt(iframeStyle.borderLeftWidth || '', 10) + parseInt(iframeStyle.paddingLeft || '', 10), top: parseInt(iframeStyle.borderTopWidth || '', 10) + parseInt(iframeStyle.paddingTop || '', 10), }; } retarget(node: Node, behavior: 'none' | 'follow-label' | 'no-follow-label' | 'button-link'): Element | null { let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement; if (!element) return null; if (behavior === 'none') return element; if (!element.matches('input, textarea, select') && !(element as any).isContentEditable) { 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; } if (behavior === 'follow-label') { if (!element.matches('a, input, textarea, button, select, [role=link], [role=button], [role=checkbox], [role=radio]') && !(element as any).isContentEditable) { // Go up to the label that might be connected to the input/textarea. const enclosingLabel: HTMLLabelElement | null = element.closest('label'); if (enclosingLabel && enclosingLabel.control) element = enclosingLabel.control; } } return element; } 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') return 'error:notconnected'; } for (const state of states) { if (state !== 'stable') { const result = this.elementState(node, state); if (result.received === 'error:notconnected') return 'error:notconnected'; if (!result.matches) return { missingState: state }; } } } private async _checkElementIsStable(node: Node): Promise<'error:notconnected' | boolean> { const continuePolling = Symbol('continuePolling'); let lastRect: { x: number, y: number, width: number, height: number } | undefined; let stableRafCounter = 0; let lastTime = 0; 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 = this.utils.builtins.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; } lastRect = rect; return continuePolling; }; 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; }); const raf = () => { try { const success = check(); if (success !== continuePolling) fulfill(success); else this.utils.builtins.requestAnimationFrame(raf); } catch (e) { reject(e); } }; this.utils.builtins.requestAnimationFrame(raf); return result; } _createAriaRefEngine() { const queryAll = (root: SelectorRoot, selector: string): Element[] => { const result = this._lastAriaSnapshot?.elements?.get(selector); return result && result.isConnected ? [result] : []; }; return { queryAll }; } elementState(node: Node, state: ElementStateWithoutStable): ElementStateQueryResult { const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label'); if (!element || !element.isConnected) { if (state === 'hidden') return { matches: true, received: 'hidden' }; return { matches: false, received: 'error:notconnected' }; } if (state === 'visible' || state === 'hidden') { const visible = isElementVisible(element); return { matches: state === 'visible' ? visible : !visible, received: visible ? 'visible' : 'hidden' }; } if (state === 'disabled' || state === 'enabled') { const disabled = getAriaDisabled(element); return { matches: state === 'disabled' ? disabled : !disabled, received: disabled ? 'disabled' : 'enabled' }; } if (state === 'editable') { const disabled = getAriaDisabled(element); const readonly = getReadonly(element); if (readonly === 'error') throw this.createStacklessError('Element is not an ,