/** * 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 type { SelectorEngine, SelectorRoot } from './selectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; import { ReactEngine } from './reactSelectorEngine'; import { VueEngine } from './vueSelectorEngine'; import { createRoleEngine } from './roleSelectorEngine'; import { parseAttributeSelector } from '../isomorphic/selectorParser'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser'; import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; import { type TextMatcher, elementMatchesText, elementText, type ElementText } from './selectorUtils'; import { SelectorEvaluatorImpl } from './selectorEvaluator'; import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils'; import type { CSSComplexSelectorList } from '../isomorphic/cssParser'; import { generateSelector } from './selectorGenerator'; import type * as channels from '@protocol/channels'; import { Highlight } from './highlight'; import { getAriaCheckedStrict, getAriaDisabled, getAriaLabelledByElements, getAriaRole, getElementAccessibleName } from './roleUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../isomorphic/locatorGenerators'; import type { Language } from '../isomorphic/locatorGenerators'; import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; type Predicate = (progress: InjectedScriptProgress) => T | symbol; export type InjectedScriptProgress = { injectedScript: InjectedScript; continuePolling: symbol; aborted: boolean; log: (message: string) => void; logRepeating: (message: string) => void; setIntermediateResult: (intermediateResult: any) => void; }; export type LogEntry = { message?: string; intermediateResult?: string; }; export type FrameExpectParams = Omit & { expectedValue?: any }; export type InjectedScriptPoll = { run: () => Promise, // Takes more logs, waiting until at least one message is available. takeNextLogs: () => Promise, // Takes all current logs without waiting. takeLastLogs: () => LogEntry[], cancel: () => void, }; export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked'; export type ElementState = ElementStateWithoutStable | 'stable'; export type HitTargetInterceptionResult = { stop: () => 'done' | { hitTargetDescription: string }; }; 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'; constructor(isUnderTest: boolean, sdkLanguage: Language, testIdAttributeNameForStrictErrorAndConsoleCodegen: string, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) { 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:label', this._createInternalLabelEngine()); this._engines.set('internal:text', this._createTextEngine(true, true)); this._engines.set('internal:has-text', this._createInternalHasTextEngine()); this._engines.set('internal:attr', this._createNamedAttributeEngine()); this._engines.set('internal:testid', this._createNamedAttributeEngine()); this._engines.set('internal:role', createRoleEngine(true)); for (const { name, engine } of customEngines) this._engines.set(name, engine); this._stableRafCount = stableRafCount; this._browserName = browserName; this._setupGlobalListenersRemovalDetection(); this._setupHitTargetInterceptors(); if (isUnderTest) (window as any).__injectedScript = this; } eval(expression: string): any { return globalThis.eval(expression); } testIdAttributeNameForStrictErrorAndConsoleCodegen(): string { return this._testIdAttributeNameForStrictErrorAndConsoleCodegen; } parseSelector(selector: string): ParsedSelector { const result = parseSelector(selector); for (const name of allEngineNames(result)) { if (!this._engines.has(name)) throw this.createStacklessError(`Unknown engine "${name}" while parsing selector ${selector}`); } return result; } generateSelector(targetElement: Element, testIdAttributeName: string): string { return generateSelector(this, targetElement, testIdAttributeName).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)); } 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.'); } 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 (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 _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 => { let labels: Element[] | NodeListOf | null | undefined = getAriaLabelledByElements(element); if (labels === null) labels = (element as HTMLInputElement).labels; return !!labels && [...labels].some(label => matcher(elementText(this._evaluator._cacheText, 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 _createVisibleEngine(): SelectorEngine { const queryAll = (root: SelectorRoot, body: string) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : []; }; return { queryAll }; } extend(source: string, params: any): any { const constrFunction = globalThis.eval(` (() => { const module = {}; ${source} return module.exports; })()`); return new constrFunction(this, params); } isVisible(element: Element): boolean { return isElementVisible(element); } pollRaf(predicate: Predicate): InjectedScriptPoll { return this.poll(predicate, next => requestAnimationFrame(next)); } pollInterval(pollInterval: number, predicate: Predicate): InjectedScriptPoll { return this.poll(predicate, next => setTimeout(next, pollInterval)); } pollLogScale(predicate: Predicate): InjectedScriptPoll { const pollIntervals = [100, 250, 500]; let attempts = 0; return this.poll(predicate, next => setTimeout(next, pollIntervals[attempts++] || 1000)); } poll(predicate: Predicate, scheduleNext: (next: () => void) => void): InjectedScriptPoll { return this._runAbortableTask(progress => { let fulfill: (result: T) => void; let reject: (error: Error) => void; const result = new Promise((f, r) => { fulfill = f; reject = r; }); const next = () => { if (progress.aborted) return; try { const success = predicate(progress); if (success !== progress.continuePolling) fulfill(success as T); else scheduleNext(next); } catch (e) { progress.log(' ' + e.message); reject(e); } }; next(); return result; }); } private _runAbortableTask(task: (progess: InjectedScriptProgress) => Promise): InjectedScriptPoll { let unsentLog: LogEntry[] = []; let takeNextLogsCallback: ((logs: LogEntry[]) => void) | undefined; let taskFinished = false; const logReady = () => { if (!takeNextLogsCallback) return; takeNextLogsCallback(unsentLog); unsentLog = []; takeNextLogsCallback = undefined; }; const takeNextLogs = () => new Promise(fulfill => { takeNextLogsCallback = fulfill; if (unsentLog.length || taskFinished) logReady(); }); let lastMessage = ''; let lastIntermediateResult: any = undefined; const progress: InjectedScriptProgress = { injectedScript: this, aborted: false, continuePolling: Symbol('continuePolling'), log: (message: string) => { lastMessage = message; unsentLog.push({ message }); logReady(); }, logRepeating: (message: string) => { if (message !== lastMessage) progress.log(message); }, setIntermediateResult: (intermediateResult: any) => { if (lastIntermediateResult === intermediateResult) return; lastIntermediateResult = intermediateResult; unsentLog.push({ intermediateResult }); logReady(); }, }; const run = () => { const result = task(progress); // After the task has finished, there should be no more logs. // Release any pending `takeNextLogs` call, and do not block any future ones. // This prevents non-finished protocol evaluation calls and memory leaks. result.finally(() => { taskFinished = true; logReady(); }); return result; }; return { takeNextLogs, run, cancel: () => { progress.aborted = true; }, takeLastLogs: () => unsentLog, }; } 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' | { borderLeft: number, borderTop: 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 { borderLeft: parseInt(iframeStyle.borderLeftWidth || '', 10), borderTop: parseInt(iframeStyle.borderTopWidth || '', 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')) { 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('input, textarea, button, select, [role=button], [role=checkbox], [role=radio]') && !(element as any).isContentEditable) { // Go up to the label that might be connected to the input/textarea. element = element.closest('label') || element; } if (element.nodeName === 'LABEL') element = (element as HTMLLabelElement).control || element; } return element; } waitForElementStatesAndPerformAction(node: Node, states: ElementState[], force: boolean | undefined, callback: (node: Node, progress: InjectedScriptProgress) => T | symbol): InjectedScriptPoll { let lastRect: { x: number, y: number, width: number, height: number } | undefined; let counter = 0; let samePositionCounter = 0; let lastTime = 0; return this.pollRaf(progress => { if (force) { progress.log(` forcing action`); return callback(node, progress); } for (const state of states) { if (state !== 'stable') { const result = this.elementState(node, state); if (typeof result !== 'boolean') return result; if (!result) { progress.logRepeating(` element is not ${state} - waiting...`); return progress.continuePolling; } continue; } const element = this.retarget(node, 'no-follow-label'); if (!element) return 'error:notconnected'; // First raf happens in the same animation frame as evaluation, so it does not produce // any client rect difference compared to synchronous call. We skip the synchronous call // and only force layout during actual rafs as a small optimisation. if (++counter === 1) return progress.continuePolling; // Drop frames that are shorter than 16ms - WebKit Win bug. const time = performance.now(); if (this._stableRafCount > 1 && time - lastTime < 15) return progress.continuePolling; lastTime = time; const clientRect = element.getBoundingClientRect(); const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height }; const samePosition = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height; if (samePosition) ++samePositionCounter; else samePositionCounter = 0; const isStable = samePositionCounter >= this._stableRafCount; const isStableForLogs = isStable || !lastRect; lastRect = rect; if (!isStableForLogs) progress.logRepeating(` element is not stable - waiting...`); if (!isStable) return progress.continuePolling; } return callback(node, progress); }); } elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' { const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'none' : 'follow-label'); if (!element || !element.isConnected) { if (state === 'hidden') return true; return 'error:notconnected'; } if (state === 'visible') return this.isVisible(element); if (state === 'hidden') return !this.isVisible(element); const disabled = getAriaDisabled(element); if (state === 'disabled') return disabled; if (state === 'enabled') return !disabled; const editable = !(['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && element.hasAttribute('readonly')); if (state === 'editable') return !disabled && editable; if (state === 'checked' || state === 'unchecked') { const need = state === 'checked'; const checked = getAriaCheckedStrict(element); if (checked === 'error') throw this.createStacklessError('Not a checkbox or radio button'); return need === checked; } throw this.createStacklessError(`Unexpected element state "${state}"`); } selectOptions(optionsToSelect: (Node | { valueOrLabel?: string, value?: string, label?: string, index?: number })[], node: Node, progress: InjectedScriptProgress): string[] | 'error:notconnected' | symbol { const element = this.retarget(node, 'follow-label'); if (!element) return 'error:notconnected'; if (element.nodeName.toLowerCase() !== 'select') throw this.createStacklessError('Element is not a ,