/** * 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 { SelectorEngine, SelectorRoot } from './selectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; import { ReactEngine } from './reactSelectorEngine'; import { VueEngine } from './vueSelectorEngine'; import { allEngineNames, ParsedSelector, ParsedSelectorPart, parseSelector, stringifySelector } from '../common/selectorParser'; import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator'; import { CSSComplexSelectorList } from '../common/cssParser'; import { generateSelector } from './selectorGenerator'; import type * as channels from '../../protocol/channels'; import { Highlight } from './highlight'; 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 interface SelectorEngineV2 { queryAll(root: SelectorRoot, body: any): Element[]; } export type ElementMatch = { element: Element; capture: Element | undefined; }; 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; constructor(isUnderTest: boolean, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine}[]) { this.isUnderTest = isUnderTest; 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('text', this._createTextEngine(true)); this._engines.set('text:light', this._createTextEngine(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', { queryAll: () => [] }); this._engines.set('control', this._createControlEngine()); this._engines.set('has', this._createHasEngine()); for (const { name, engine } of customEngines) this._engines.set(name, engine); this._stableRafCount = stableRafCount; this._browserName = browserName; this._setupGlobalListenersRemovalDetection(); this._setupHitTargetInterceptors(); } eval(expression: string): any { return global.eval(expression); } 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): string { return generateSelector(this, targetElement, true).selector; } querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined { if (!(root as any)['querySelector']) throw this.createStacklessError('Node is not queryable.'); this._evaluator.begin(); try { const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map()); if (strict && result.length > 1) throw this.strictModeViolationError(selector, result.map(r => r.element)); return result[0]?.capture || result[0]?.element; } finally { this._evaluator.end(); } } private _querySelectorRecursively(roots: ElementMatch[], selector: ParsedSelector, index: number, queryCache: Map): ElementMatch[] { if (index === selector.parts.length) return roots; const part = selector.parts[index]; if (part.name === 'nth') { let filtered: ElementMatch[] = []; if (part.body === '0') { filtered = roots.slice(0, 1); } else if (part.body === '-1') { if (roots.length) filtered = roots.slice(roots.length - 1); } else { if (typeof selector.capture === 'number') throw this.createStacklessError(`Can't query n-th element in a request with the capture.`); const nth = +part.body; const set = new Set(); for (const root of roots) { set.add(root.element); if (nth + 1 === set.size) filtered = [root]; } } return this._querySelectorRecursively(filtered, selector, index + 1, queryCache); } if (part.name === 'visible') { const visible = Boolean(part.body); const filtered = roots.filter(match => visible === isVisible(match.element)); return this._querySelectorRecursively(filtered, selector, index + 1, queryCache); } const result: ElementMatch[] = []; for (const root of roots) { const capture = index - 1 === selector.capture ? root.element : root.capture; // Do not query engine twice for the same element. let queryResults = queryCache.get(root.element); if (!queryResults) { queryResults = []; queryCache.set(root.element, queryResults); } let all = queryResults[index]; if (!all) { all = this._queryEngineAll(part, root.element); queryResults[index] = all; } for (const element of all) { if (!('nodeName' in element)) throw this.createStacklessError(`Expected a Node but got ${Object.prototype.toString.call(element)}`); result.push({ element, capture }); } } return this._querySelectorRecursively(result, selector, index + 1, queryCache); } querySelectorAll(selector: ParsedSelector, root: Node): Element[] { if (!(root as any)['querySelectorAll']) throw this.createStacklessError('Node is not queryable.'); this._evaluator.begin(); try { const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map()); const set = new Set(); for (const r of result) set.add(r.capture || r.element); return [...set]; } finally { this._evaluator.end(); } } private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] { return this._engines.get(part.name)!.queryAll(root, part.body); } 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(): SelectorEngineV2 { const evaluator = this._evaluator; return { queryAll(root: SelectorRoot, body: any) { return evaluator.query({ scope: root as Document | Element, pierceShadow: true }, body); } }; } private _createTextEngine(shadow: boolean): SelectorEngine { const queryList = (root: SelectorRoot, selector: string): Element[] => { const { matcher, kind } = createTextMatcher(selector); 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, element, matcher); if (matches === 'none') lastDidNotMatchSelf = element; if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict')) 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: (root: SelectorRoot, selector: string): Element[] => { return queryList(root, selector); } }; } private _createControlEngine(): SelectorEngineV2 { return { queryAll(root: SelectorRoot, body: any) { if (body === 'enter-frame') return []; if (body === 'return-empty') return []; throw new Error(`Internal error, unknown control selector ${body}`); } }; } private _createHasEngine(): SelectorEngineV2 { const queryAll = (root: SelectorRoot, body: ParsedSelector) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; const has = !!this.querySelector(body, root, false); return has ? [root as Element] : []; }; return { queryAll }; } extend(source: string, params: any): any { const constrFunction = global.eval(` (() => { ${source} return pwExport; })()`); return new constrFunction(this, params); } isVisible(element: Element): boolean { return isVisible(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) }; } retarget(node: Node, behavior: 'follow-label' | 'no-follow-label'): Element | null { let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement; if (!element) return null; if (!element.matches('input, textarea, select')) 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) ? 'no-follow-label' : '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 = isElementDisabled(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') { if (['checkbox', 'radio'].includes(element.getAttribute('role') || '')) { const result = element.getAttribute('aria-checked') === 'true'; return state === 'checked' ? result : !result; } if (element.nodeName !== 'INPUT') throw this.createStacklessError('Not a checkbox or radio button'); if (!['radio', 'checkbox'].includes((element as HTMLInputElement).type.toLowerCase())) throw this.createStacklessError('Not a checkbox or radio button'); const result = (element as HTMLInputElement).checked; return state === 'checked' ? result : !result; } throw this.createStacklessError(`Unexpected element state "${state}"`); } selectOptions(optionsToSelect: (Node | { 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 ,