/** * 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 { generateAriaTree, getAllByAria, matchesAriaTree, renderAriaTree } from './ariaSnapshot'; import { enclosingShadowRootOrDocument, isElementVisible, isInsideScope, parentElementOrShadowHost, setBrowserName } from './domUtils'; import { Highlight } from './highlight'; import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils'; import { ReactEngine } from './reactSelectorEngine'; import { createRoleEngine } from './roleSelectorEngine'; import { 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 { VueEngine } from './vueSelectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { parseAttributeSelector } from '../../utils/isomorphic/selectorParser'; import { parseSelector, stringifySelector, visitAllSelectorParts } from '../../utils/isomorphic/selectorParser'; import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; import type { AriaNode, AriaSnapshot } 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 { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../../utils/isomorphic/selectorParser'; import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot'; import type * as channels from '@protocol/channels'; 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' }; 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 class InjectedScript { private _engines: Map; _evaluator: SelectorEvaluatorImpl; private _stableRafCount: number; private _browserName: string; onGlobalListenersRemoved = new 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 }; // eslint-disable-next-line no-restricted-globals readonly window: Window & typeof globalThis; readonly document: Document; private _ariaElementById: Map | 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, }; // 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; this.isUnderTest = isUnderTest; this._sdkLanguage = sdkLanguage; this._testIdAttributeNameForStrictErrorAndConsoleCodegen = testIdAttributeNameForStrictErrorAndConsoleCodegen; this._evaluator = new SelectorEvaluatorImpl(new Map()); this._engines = new Map(); this._engines.set('xpath', XPathEngine); this._engines.set('xpath:light', XPathEngine); this._engines.set('_react', ReactEngine); this._engines.set('_vue', VueEngine); 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:aria-id', this._createAriaIdEngine()); for (const { name, engine } of customEngines) this._engines.set(name, engine); this._stableRafCount = stableRafCount; this._browserName = browserName; setBrowserName(browserName); this._setupGlobalListenersRemovalDetection(); this._setupHitTargetInterceptors(); if (isUnderTest) (this.window as any).__injectedScript = this; } builtinSetTimeout(callback: Function, timeout: number) { if (this.window.__pwClock?.builtin) return this.window.__pwClock.builtin.setTimeout(callback, timeout); 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); } builtinRequestAnimationFrame(callback: FrameRequestCallback) { if (this.window.__pwClock?.builtin) return this.window.__pwClock.builtin.requestAnimationFrame(callback); return this.window.requestAnimationFrame(callback); } 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?: { mode?: 'raw' | 'regex', id?: boolean }): string { if (node.nodeType !== Node.ELEMENT_NODE) throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); const ariaSnapshot = generateAriaTree(node as Element); this._ariaElementById = ariaSnapshot.elements; return renderAriaTree(ariaSnapshot.root, { ...options, ids: options?.id ? ariaSnapshot.ids : undefined }); } 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 }); } getAllByAria(document: Document, template: AriaTemplateNode): Element[] { return getAllByAria(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 _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.builtinRequestAnimationFrame(() => {}); }); } 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 = 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.builtinRequestAnimationFrame(raf); } catch (e) { reject(e); } }; this.builtinRequestAnimationFrame(raf); return result; } _createAriaIdEngine() { const queryAll = (root: SelectorRoot, selector: string): Element[] => { const ariaId = parseInt(selector, 10); const result = this._ariaElementById?.get(ariaId); 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 ,