2020-01-06 18:22:35 -08:00
|
|
|
/**
|
|
|
|
* Copyright (c) Microsoft Corporation.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
2019-11-21 14:43:30 -08:00
|
|
|
|
2020-05-15 15:21:49 -07:00
|
|
|
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
|
|
|
import { XPathEngine } from './xpathSelectorEngine';
|
2021-08-08 02:51:39 +03:00
|
|
|
import { ReactEngine } from './reactSelectorEngine';
|
2020-12-17 17:01:46 -08:00
|
|
|
import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser';
|
2020-08-24 06:51:51 -07:00
|
|
|
import { FatalDOMError } from '../common/domErrors';
|
2021-03-03 10:51:10 -08:00
|
|
|
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
|
2021-01-08 14:51:43 -08:00
|
|
|
import { CSSComplexSelectorList } from '../common/cssParser';
|
2019-12-16 20:49:18 -08:00
|
|
|
|
2020-08-24 15:30:45 -07:00
|
|
|
type Predicate<T> = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;
|
2019-11-22 15:36:17 -08:00
|
|
|
|
2020-08-24 15:30:45 -07:00
|
|
|
export type InjectedScriptProgress = {
|
|
|
|
aborted: boolean,
|
|
|
|
log: (message: string) => void,
|
|
|
|
logRepeating: (message: string) => void,
|
|
|
|
};
|
|
|
|
|
|
|
|
export type InjectedScriptPoll<T> = {
|
2021-01-19 11:27:05 -08:00
|
|
|
run: () => Promise<T>,
|
2020-08-24 15:30:45 -07:00
|
|
|
// Takes more logs, waiting until at least one message is available.
|
|
|
|
takeNextLogs: () => Promise<string[]>,
|
|
|
|
// Takes all current logs without waiting.
|
|
|
|
takeLastLogs: () => string[],
|
|
|
|
cancel: () => void,
|
|
|
|
};
|
|
|
|
|
2021-02-10 12:36:26 -08:00
|
|
|
export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked';
|
|
|
|
export type ElementState = ElementStateWithoutStable | 'stable';
|
|
|
|
|
2021-07-26 15:07:12 -07:00
|
|
|
export interface SelectorEngineV2 {
|
|
|
|
queryAll(root: SelectorRoot, body: any): Element[];
|
|
|
|
}
|
|
|
|
|
2021-07-27 12:53:12 -07:00
|
|
|
export type ElementMatch = {
|
|
|
|
element: Element;
|
|
|
|
capture: Element | undefined;
|
|
|
|
};
|
|
|
|
|
2020-08-24 15:30:45 -07:00
|
|
|
export class InjectedScript {
|
2021-07-26 15:07:12 -07:00
|
|
|
private _engines: Map<string, SelectorEngineV2>;
|
2021-02-08 21:53:17 -08:00
|
|
|
_evaluator: SelectorEvaluatorImpl;
|
2021-02-10 12:36:26 -08:00
|
|
|
private _stableRafCount: number;
|
|
|
|
private _replaceRafWithTimeout: boolean;
|
2020-05-15 15:21:49 -07:00
|
|
|
|
2021-02-10 12:36:26 -08:00
|
|
|
constructor(stableRafCount: number, replaceRafWithTimeout: boolean, customEngines: { name: string, engine: SelectorEngine}[]) {
|
2021-07-26 15:07:12 -07:00
|
|
|
this._evaluator = new SelectorEvaluatorImpl(new Map());
|
|
|
|
|
|
|
|
this._engines = new Map();
|
|
|
|
this._engines.set('xpath', XPathEngine);
|
|
|
|
this._engines.set('xpath:light', XPathEngine);
|
2021-08-08 02:51:39 +03:00
|
|
|
this._engines.set('react', ReactEngine);
|
2021-07-26 15:07:12 -07:00
|
|
|
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());
|
2021-07-27 12:53:12 -07:00
|
|
|
this._engines.set('_first', { queryAll: () => [] });
|
|
|
|
this._engines.set('_visible', { queryAll: () => [] });
|
2021-07-27 15:58:18 -07:00
|
|
|
this._engines.set('_nth', { queryAll: () => [] });
|
2021-07-26 22:00:23 -07:00
|
|
|
|
2020-12-04 06:51:18 -08:00
|
|
|
for (const { name, engine } of customEngines)
|
2021-07-26 15:07:12 -07:00
|
|
|
this._engines.set(name, engine);
|
2020-12-04 06:51:18 -08:00
|
|
|
|
2021-02-10 12:36:26 -08:00
|
|
|
this._stableRafCount = stableRafCount;
|
|
|
|
this._replaceRafWithTimeout = replaceRafWithTimeout;
|
2020-05-15 15:21:49 -07:00
|
|
|
}
|
|
|
|
|
2020-09-06 18:19:32 -07:00
|
|
|
parseSelector(selector: string): ParsedSelector {
|
2020-12-17 17:01:46 -08:00
|
|
|
const result = parseSelector(selector);
|
|
|
|
for (const part of result.parts) {
|
2021-07-26 15:07:12 -07:00
|
|
|
if (!this._engines.has(part.name))
|
2020-12-17 17:01:46 -08:00
|
|
|
throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`);
|
|
|
|
}
|
|
|
|
return result;
|
2020-09-06 18:19:32 -07:00
|
|
|
}
|
|
|
|
|
2021-07-26 22:00:23 -07:00
|
|
|
querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined {
|
2020-05-15 15:21:49 -07:00
|
|
|
if (!(root as any)['querySelector'])
|
|
|
|
throw new Error('Node is not queryable.');
|
2021-02-08 21:53:17 -08:00
|
|
|
this._evaluator.begin();
|
|
|
|
try {
|
2021-07-27 12:53:12 -07:00
|
|
|
const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map());
|
|
|
|
if (strict && result.length > 1)
|
|
|
|
throw new Error(`strict mode violation: selector resolved to ${result.length} elements.`);
|
|
|
|
return result[0]?.capture || result[0]?.element;
|
2021-02-08 21:53:17 -08:00
|
|
|
} finally {
|
|
|
|
this._evaluator.end();
|
|
|
|
}
|
2020-05-15 15:21:49 -07:00
|
|
|
}
|
|
|
|
|
2021-07-27 12:53:12 -07:00
|
|
|
private _querySelectorRecursively(roots: ElementMatch[], selector: ParsedSelector, index: number, queryCache: Map<Element, Element[][]>): ElementMatch[] {
|
|
|
|
if (index === selector.parts.length)
|
|
|
|
return roots;
|
|
|
|
|
2021-07-27 15:58:18 -07:00
|
|
|
const part = selector.parts[index];
|
|
|
|
if (part.name === '_nth') {
|
|
|
|
let filtered: ElementMatch[] = [];
|
|
|
|
if (part.body === 'first') {
|
|
|
|
filtered = roots.slice(0, 1);
|
|
|
|
} else if (part.body === 'last') {
|
|
|
|
if (roots.length)
|
|
|
|
filtered = roots.slice(roots.length - 1);
|
|
|
|
} else {
|
|
|
|
if (typeof selector.capture === 'number')
|
|
|
|
throw new Error(`Can't query n-th element in a request with the capture.`);
|
|
|
|
const nth = +part.body;
|
|
|
|
const set = new Set<Element>();
|
|
|
|
for (const root of roots) {
|
|
|
|
set.add(root.element);
|
|
|
|
if (nth + 1 === set.size)
|
|
|
|
filtered = [root];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return this._querySelectorRecursively(filtered, selector, index + 1, queryCache);
|
|
|
|
}
|
2021-07-27 12:53:12 -07:00
|
|
|
|
2021-07-27 15:58:18 -07:00
|
|
|
if (part.name === '_visible') {
|
|
|
|
const visible = Boolean(part.body);
|
2021-07-27 12:53:12 -07:00
|
|
|
return roots.filter(match => visible === isVisible(match.element));
|
2021-07-26 22:00:23 -07:00
|
|
|
}
|
2021-07-27 12:53:12 -07:00
|
|
|
|
|
|
|
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(selector.parts[index], root.element);
|
|
|
|
queryResults[index] = all;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const element of all)
|
|
|
|
result.push({ element, capture });
|
2020-05-15 15:21:49 -07:00
|
|
|
}
|
2021-07-27 12:53:12 -07:00
|
|
|
return this._querySelectorRecursively(result, selector, index + 1, queryCache);
|
2020-05-15 15:21:49 -07:00
|
|
|
}
|
|
|
|
|
2020-06-11 18:18:33 -07:00
|
|
|
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
2020-05-15 15:21:49 -07:00
|
|
|
if (!(root as any)['querySelectorAll'])
|
|
|
|
throw new Error('Node is not queryable.');
|
2021-02-08 21:53:17 -08:00
|
|
|
this._evaluator.begin();
|
|
|
|
try {
|
2021-07-27 12:53:12 -07:00
|
|
|
const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map());
|
|
|
|
const set = new Set<Element>();
|
|
|
|
for (const r of result)
|
|
|
|
set.add(r.capture || r.element);
|
|
|
|
return [...set];
|
2021-02-08 21:53:17 -08:00
|
|
|
} finally {
|
|
|
|
this._evaluator.end();
|
2021-02-04 17:44:55 -08:00
|
|
|
}
|
2020-12-17 17:01:46 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] {
|
2021-07-26 15:07:12 -07:00
|
|
|
return this._engines.get(part.name)!.queryAll(root, part.body);
|
2020-05-15 15:21:49 -07:00
|
|
|
}
|
|
|
|
|
2021-01-08 14:51:43 -08:00
|
|
|
private _createAttributeEngine(attribute: string, shadow: boolean): SelectorEngine {
|
|
|
|
const toCSS = (selector: string): CSSComplexSelectorList => {
|
|
|
|
const css = `[${attribute}=${JSON.stringify(selector)}]`;
|
|
|
|
return [{ simples: [{ selector: { css, functions: [] }, combinator: '' }] }];
|
|
|
|
};
|
|
|
|
return {
|
2021-02-04 17:44:55 -08:00
|
|
|
queryAll: (root: SelectorRoot, selector: string): Element[] => {
|
|
|
|
return this._evaluator.query({ scope: root as Document | Element, pierceShadow: shadow }, toCSS(selector));
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-07-26 15:07:12 -07:00
|
|
|
private _createCSSEngine(): SelectorEngineV2 {
|
|
|
|
const evaluator = this._evaluator;
|
|
|
|
return {
|
|
|
|
queryAll(root: SelectorRoot, body: any) {
|
|
|
|
return evaluator.query({ scope: root as Document | Element, pierceShadow: true }, body);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-02-04 17:44:55 -08:00
|
|
|
private _createTextEngine(shadow: boolean): SelectorEngine {
|
2021-07-27 12:53:12 -07:00
|
|
|
const queryList = (root: SelectorRoot, selector: string): Element[] => {
|
2021-03-03 10:51:10 -08:00
|
|
|
const { matcher, kind } = createTextMatcher(selector);
|
2021-02-09 21:31:46 -08:00
|
|
|
const result: Element[] = [];
|
|
|
|
let lastDidNotMatchSelf: Element | null = null;
|
|
|
|
|
2021-07-26 15:07:12 -07:00
|
|
|
const appendElement = (element: Element) => {
|
2021-02-09 21:31:46 -08:00
|
|
|
// TODO: replace contains() with something shadow-dom-aware?
|
2021-03-03 10:51:10 -08:00
|
|
|
if (kind === 'lax' && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element))
|
2021-02-09 21:31:46 -08:00
|
|
|
return false;
|
|
|
|
const matches = elementMatchesText(this._evaluator, element, matcher);
|
|
|
|
if (matches === 'none')
|
|
|
|
lastDidNotMatchSelf = element;
|
2021-03-03 10:51:10 -08:00
|
|
|
if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict'))
|
2021-02-09 21:31:46 -08:00
|
|
|
result.push(element);
|
|
|
|
};
|
|
|
|
|
2021-07-27 12:53:12 -07:00
|
|
|
if (root.nodeType === Node.ELEMENT_NODE)
|
|
|
|
appendElement(root as Element);
|
2021-02-09 21:31:46 -08:00
|
|
|
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
|
2021-07-27 12:53:12 -07:00
|
|
|
for (const element of elements)
|
|
|
|
appendElement(element);
|
2021-02-09 21:31:46 -08:00
|
|
|
return result;
|
|
|
|
};
|
|
|
|
|
2021-02-04 17:44:55 -08:00
|
|
|
return {
|
2021-01-08 14:51:43 -08:00
|
|
|
queryAll: (root: SelectorRoot, selector: string): Element[] => {
|
2021-07-27 12:53:12 -07:00
|
|
|
return queryList(root, selector);
|
2021-01-08 14:51:43 -08:00
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-09-06 18:19:32 -07:00
|
|
|
extend(source: string, params: any): any {
|
2021-01-08 16:15:05 -08:00
|
|
|
const constrFunction = global.eval(`
|
|
|
|
(() => {
|
|
|
|
${source}
|
|
|
|
return pwExport;
|
|
|
|
})()`);
|
2020-09-06 18:19:32 -07:00
|
|
|
return new constrFunction(this, params);
|
|
|
|
}
|
|
|
|
|
2019-12-04 13:11:10 -08:00
|
|
|
isVisible(element: Element): boolean {
|
2020-12-06 15:03:36 -08:00
|
|
|
return isVisible(element);
|
2019-12-04 13:11:10 -08:00
|
|
|
}
|
|
|
|
|
2020-08-24 15:30:45 -07:00
|
|
|
pollRaf<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {
|
2020-06-24 15:12:17 -07:00
|
|
|
return this._runAbortableTask(progress => {
|
|
|
|
let fulfill: (result: T) => void;
|
|
|
|
let reject: (error: Error) => void;
|
|
|
|
const result = new Promise<T>((f, r) => { fulfill = f; reject = r; });
|
|
|
|
|
|
|
|
const onRaf = () => {
|
|
|
|
if (progress.aborted)
|
|
|
|
return;
|
|
|
|
try {
|
|
|
|
const continuePolling = Symbol('continuePolling');
|
|
|
|
const success = predicate(progress, continuePolling);
|
|
|
|
if (success !== continuePolling)
|
|
|
|
fulfill(success as T);
|
|
|
|
else
|
|
|
|
requestAnimationFrame(onRaf);
|
|
|
|
} catch (e) {
|
2021-07-26 22:00:23 -07:00
|
|
|
progress.log(' ' + e.message);
|
2020-06-24 15:12:17 -07:00
|
|
|
reject(e);
|
|
|
|
}
|
|
|
|
};
|
2019-12-18 18:11:02 -08:00
|
|
|
|
2020-06-24 15:12:17 -07:00
|
|
|
onRaf();
|
|
|
|
return result;
|
|
|
|
});
|
2019-12-03 10:51:41 -08:00
|
|
|
}
|
|
|
|
|
2020-08-24 15:30:45 -07:00
|
|
|
pollInterval<T>(pollInterval: number, predicate: Predicate<T>): InjectedScriptPoll<T> {
|
2020-06-24 15:12:17 -07:00
|
|
|
return this._runAbortableTask(progress => {
|
|
|
|
let fulfill: (result: T) => void;
|
|
|
|
let reject: (error: Error) => void;
|
|
|
|
const result = new Promise<T>((f, r) => { fulfill = f; reject = r; });
|
|
|
|
|
|
|
|
const onTimeout = () => {
|
|
|
|
if (progress.aborted)
|
|
|
|
return;
|
|
|
|
try {
|
|
|
|
const continuePolling = Symbol('continuePolling');
|
|
|
|
const success = predicate(progress, continuePolling);
|
|
|
|
if (success !== continuePolling)
|
|
|
|
fulfill(success as T);
|
|
|
|
else
|
|
|
|
setTimeout(onTimeout, pollInterval);
|
|
|
|
} catch (e) {
|
|
|
|
reject(e);
|
|
|
|
}
|
|
|
|
};
|
2019-12-18 18:11:02 -08:00
|
|
|
|
2020-06-24 15:12:17 -07:00
|
|
|
onTimeout();
|
|
|
|
return result;
|
|
|
|
});
|
2019-12-03 10:51:41 -08:00
|
|
|
}
|
2020-02-19 09:34:57 -08:00
|
|
|
|
2020-08-24 15:30:45 -07:00
|
|
|
private _runAbortableTask<T>(task: (progess: InjectedScriptProgress) => Promise<T>): InjectedScriptPoll<T> {
|
2020-06-25 13:13:10 -07:00
|
|
|
let unsentLogs: string[] = [];
|
|
|
|
let takeNextLogsCallback: ((logs: string[]) => void) | undefined;
|
2020-11-16 14:11:55 -08:00
|
|
|
let taskFinished = false;
|
2020-06-25 13:13:10 -07:00
|
|
|
const logReady = () => {
|
|
|
|
if (!takeNextLogsCallback)
|
|
|
|
return;
|
|
|
|
takeNextLogsCallback(unsentLogs);
|
|
|
|
unsentLogs = [];
|
|
|
|
takeNextLogsCallback = undefined;
|
|
|
|
};
|
|
|
|
|
|
|
|
const takeNextLogs = () => new Promise<string[]>(fulfill => {
|
|
|
|
takeNextLogsCallback = fulfill;
|
2020-11-16 14:11:55 -08:00
|
|
|
if (unsentLogs.length || taskFinished)
|
2020-06-25 13:13:10 -07:00
|
|
|
logReady();
|
2020-06-01 15:48:23 -07:00
|
|
|
});
|
|
|
|
|
2020-06-10 18:45:18 -07:00
|
|
|
let lastLog = '';
|
2020-08-24 15:30:45 -07:00
|
|
|
const progress: InjectedScriptProgress = {
|
2020-06-24 15:12:17 -07:00
|
|
|
aborted: false,
|
2020-06-01 15:48:23 -07:00
|
|
|
log: (message: string) => {
|
2020-06-10 18:45:18 -07:00
|
|
|
lastLog = message;
|
2020-06-25 13:13:10 -07:00
|
|
|
unsentLogs.push(message);
|
2020-06-01 15:48:23 -07:00
|
|
|
logReady();
|
|
|
|
},
|
2020-06-10 18:45:18 -07:00
|
|
|
logRepeating: (message: string) => {
|
|
|
|
if (message !== lastLog)
|
|
|
|
progress.log(message);
|
|
|
|
},
|
2020-06-01 15:48:23 -07:00
|
|
|
};
|
|
|
|
|
2021-01-19 11:27:05 -08:00
|
|
|
const run = () => {
|
|
|
|
const result = task(progress);
|
2020-11-16 14:11:55 -08:00
|
|
|
|
2021-01-19 11:27:05 -08:00
|
|
|
// 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;
|
|
|
|
};
|
2020-11-16 14:11:55 -08:00
|
|
|
|
2020-06-01 15:48:23 -07:00
|
|
|
return {
|
2020-06-25 13:13:10 -07:00
|
|
|
takeNextLogs,
|
2021-01-19 11:27:05 -08:00
|
|
|
run,
|
2020-06-24 15:12:17 -07:00
|
|
|
cancel: () => { progress.aborted = true; },
|
2020-06-25 13:13:10 -07:00
|
|
|
takeLastLogs: () => unsentLogs,
|
2020-06-01 15:48:23 -07:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-02-25 07:06:20 -08:00
|
|
|
getElementBorderWidth(node: Node): { left: number; top: number; } {
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE || !node.ownerDocument || !node.ownerDocument.defaultView)
|
|
|
|
return { left: 0, top: 0 };
|
|
|
|
const style = node.ownerDocument.defaultView.getComputedStyle(node as Element);
|
|
|
|
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
|
|
|
|
}
|
|
|
|
|
2021-06-28 14:18:01 -07:00
|
|
|
retarget(node: Node, behavior: 'follow-label' | 'no-follow-label'): Element | null {
|
2021-02-10 12:36:26 -08:00
|
|
|
let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
|
|
|
|
if (!element)
|
|
|
|
return null;
|
2021-04-01 12:30:05 -07:00
|
|
|
if (!element.matches('input, textarea, select'))
|
|
|
|
element = element.closest('button, [role=button], [role=checkbox], [role=radio]') || element;
|
2021-03-02 17:29:03 -08:00
|
|
|
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;
|
2021-02-10 12:36:26 -08:00
|
|
|
}
|
|
|
|
return element;
|
2020-02-25 07:06:20 -08:00
|
|
|
}
|
|
|
|
|
2021-06-24 08:18:09 -07:00
|
|
|
waitForElementStatesAndPerformAction<T>(node: Node, states: ElementState[], force: boolean | undefined,
|
2021-03-02 17:29:03 -08:00
|
|
|
callback: (node: Node, progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol): InjectedScriptPoll<T | 'error:notconnected' | FatalDOMError> {
|
2021-02-10 12:36:26 -08:00
|
|
|
let lastRect: { x: number, y: number, width: number, height: number } | undefined;
|
|
|
|
let counter = 0;
|
|
|
|
let samePositionCounter = 0;
|
|
|
|
let lastTime = 0;
|
2021-01-25 13:40:19 -08:00
|
|
|
|
2021-02-10 12:36:26 -08:00
|
|
|
const predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => {
|
2021-06-24 08:18:09 -07:00
|
|
|
if (force) {
|
|
|
|
progress.log(` forcing action`);
|
|
|
|
return callback(node, progress, continuePolling);
|
|
|
|
}
|
|
|
|
|
2021-02-10 12:36:26 -08:00
|
|
|
for (const state of states) {
|
|
|
|
if (state !== 'stable') {
|
2021-03-02 17:29:03 -08:00
|
|
|
const result = this.checkElementState(node, state);
|
2021-02-10 12:36:26 -08:00
|
|
|
if (typeof result !== 'boolean')
|
|
|
|
return result;
|
|
|
|
if (!result) {
|
|
|
|
progress.logRepeating(` element is not ${state} - waiting...`);
|
|
|
|
return continuePolling;
|
|
|
|
}
|
|
|
|
continue;
|
2020-06-10 18:45:18 -07:00
|
|
|
}
|
2021-02-10 12:36:26 -08:00
|
|
|
|
2021-06-28 14:18:01 -07:00
|
|
|
const element = this.retarget(node, 'no-follow-label');
|
2021-02-10 12:36:26 -08:00
|
|
|
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)
|
2020-06-24 15:12:17 -07:00
|
|
|
return continuePolling;
|
2021-02-10 12:36:26 -08:00
|
|
|
|
|
|
|
// Drop frames that are shorter than 16ms - WebKit Win bug.
|
|
|
|
const time = performance.now();
|
|
|
|
if (this._stableRafCount > 1 && time - lastTime < 15)
|
2020-06-24 15:12:17 -07:00
|
|
|
return continuePolling;
|
2021-02-10 12:36:26 -08:00
|
|
|
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)
|
2020-06-24 15:12:17 -07:00
|
|
|
return continuePolling;
|
2020-04-07 10:07:06 -07:00
|
|
|
}
|
2021-02-10 12:36:26 -08:00
|
|
|
|
2021-03-02 17:29:03 -08:00
|
|
|
return callback(node, progress, continuePolling);
|
2021-02-10 12:36:26 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
if (this._replaceRafWithTimeout)
|
|
|
|
return this.pollInterval(16, predicate);
|
|
|
|
else
|
|
|
|
return this.pollRaf(predicate);
|
|
|
|
}
|
|
|
|
|
2021-03-02 17:29:03 -08:00
|
|
|
checkElementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
|
2021-06-28 14:18:01 -07:00
|
|
|
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'no-follow-label' : 'follow-label');
|
2021-02-10 12:36:26 -08:00
|
|
|
if (!element || !element.isConnected) {
|
|
|
|
if (state === 'hidden')
|
|
|
|
return true;
|
|
|
|
return 'error:notconnected';
|
|
|
|
}
|
2021-03-02 17:29:03 -08:00
|
|
|
|
2021-02-10 12:36:26 -08:00
|
|
|
if (state === 'visible')
|
|
|
|
return this.isVisible(element);
|
|
|
|
if (state === 'hidden')
|
|
|
|
return !this.isVisible(element);
|
|
|
|
|
|
|
|
const disabled = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(element.nodeName) && element.hasAttribute('disabled');
|
|
|
|
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') {
|
|
|
|
if (element.getAttribute('role') === 'checkbox')
|
|
|
|
return element.getAttribute('aria-checked') === 'true';
|
|
|
|
if (element.nodeName !== 'INPUT')
|
|
|
|
return 'error:notcheckbox';
|
|
|
|
if (!['radio', 'checkbox'].includes((element as HTMLInputElement).type.toLowerCase()))
|
|
|
|
return 'error:notcheckbox';
|
|
|
|
return (element as HTMLInputElement).checked;
|
|
|
|
}
|
|
|
|
throw new Error(`Unexpected element state "${state}"`);
|
|
|
|
}
|
|
|
|
|
|
|
|
selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[],
|
2021-03-02 17:29:03 -08:00
|
|
|
node: Node, progress: InjectedScriptProgress, continuePolling: symbol): string[] | 'error:notconnected' | FatalDOMError | symbol {
|
2021-06-28 14:18:01 -07:00
|
|
|
const element = this.retarget(node, 'follow-label');
|
2021-02-10 12:36:26 -08:00
|
|
|
if (!element)
|
|
|
|
return 'error:notconnected';
|
|
|
|
if (element.nodeName.toLowerCase() !== 'select')
|
|
|
|
return 'error:notselect';
|
|
|
|
const select = element as HTMLSelectElement;
|
2021-06-03 15:10:02 -07:00
|
|
|
const options = [...select.options];
|
2021-02-10 12:36:26 -08:00
|
|
|
const selectedOptions = [];
|
|
|
|
let remainingOptionsToSelect = optionsToSelect.slice();
|
|
|
|
for (let index = 0; index < options.length; index++) {
|
|
|
|
const option = options[index];
|
|
|
|
const filter = (optionToSelect: Node | { value?: string, label?: string, index?: number }) => {
|
|
|
|
if (optionToSelect instanceof Node)
|
|
|
|
return option === optionToSelect;
|
|
|
|
let matches = true;
|
|
|
|
if (optionToSelect.value !== undefined)
|
|
|
|
matches = matches && optionToSelect.value === option.value;
|
|
|
|
if (optionToSelect.label !== undefined)
|
|
|
|
matches = matches && optionToSelect.label === option.label;
|
|
|
|
if (optionToSelect.index !== undefined)
|
|
|
|
matches = matches && optionToSelect.index === index;
|
|
|
|
return matches;
|
|
|
|
};
|
|
|
|
if (!remainingOptionsToSelect.some(filter))
|
|
|
|
continue;
|
|
|
|
selectedOptions.push(option);
|
|
|
|
if (select.multiple) {
|
|
|
|
remainingOptionsToSelect = remainingOptionsToSelect.filter(o => !filter(o));
|
|
|
|
} else {
|
|
|
|
remainingOptionsToSelect = [];
|
|
|
|
break;
|
2020-06-23 13:02:31 -07:00
|
|
|
}
|
2021-02-10 12:36:26 -08:00
|
|
|
}
|
|
|
|
if (remainingOptionsToSelect.length) {
|
|
|
|
progress.logRepeating(' did not find some options - waiting... ');
|
|
|
|
return continuePolling;
|
|
|
|
}
|
|
|
|
select.value = undefined as any;
|
|
|
|
selectedOptions.forEach(option => option.selected = true);
|
|
|
|
progress.log(' selected specified option(s)');
|
|
|
|
select.dispatchEvent(new Event('input', { 'bubbles': true }));
|
|
|
|
select.dispatchEvent(new Event('change', { 'bubbles': true }));
|
|
|
|
return selectedOptions.map(option => option.value);
|
2020-04-14 17:09:26 -07:00
|
|
|
}
|
|
|
|
|
2021-03-02 17:29:03 -08:00
|
|
|
fill(value: string, node: Node, progress: InjectedScriptProgress): FatalDOMError | 'error:notconnected' | 'needsinput' | 'done' {
|
2021-06-28 14:18:01 -07:00
|
|
|
const element = this.retarget(node, 'follow-label');
|
2021-02-10 12:36:26 -08:00
|
|
|
if (!element)
|
|
|
|
return 'error:notconnected';
|
|
|
|
if (element.nodeName.toLowerCase() === 'input') {
|
|
|
|
const input = element as HTMLInputElement;
|
|
|
|
const type = input.type.toLowerCase();
|
|
|
|
const kDateTypes = new Set(['date', 'time', 'datetime', 'datetime-local', 'month', 'week']);
|
|
|
|
const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']);
|
|
|
|
if (!kTextInputTypes.has(type) && !kDateTypes.has(type)) {
|
|
|
|
progress.log(` input of type "${type}" cannot be filled`);
|
|
|
|
return 'error:notfillableinputtype';
|
2020-06-23 13:02:31 -07:00
|
|
|
}
|
2021-02-10 12:36:26 -08:00
|
|
|
if (type === 'number') {
|
|
|
|
value = value.trim();
|
|
|
|
if (isNaN(Number(value)))
|
|
|
|
return 'error:notfillablenumberinput';
|
2020-06-23 13:02:31 -07:00
|
|
|
}
|
2021-02-10 12:36:26 -08:00
|
|
|
if (kDateTypes.has(type)) {
|
|
|
|
value = value.trim();
|
|
|
|
input.focus();
|
|
|
|
input.value = value;
|
|
|
|
if (input.value !== value)
|
|
|
|
return 'error:notvaliddate';
|
|
|
|
element.dispatchEvent(new Event('input', { 'bubbles': true }));
|
|
|
|
element.dispatchEvent(new Event('change', { 'bubbles': true }));
|
|
|
|
return 'done'; // We have already changed the value, no need to input it.
|
|
|
|
}
|
|
|
|
} else if (element.nodeName.toLowerCase() === 'textarea') {
|
|
|
|
// Nothing to check here.
|
|
|
|
} else if (!(element as HTMLElement).isContentEditable) {
|
|
|
|
return 'error:notfillableelement';
|
|
|
|
}
|
|
|
|
this.selectText(element);
|
|
|
|
return 'needsinput'; // Still need to input the value.
|
2020-06-23 13:02:31 -07:00
|
|
|
}
|
|
|
|
|
2021-03-02 17:29:03 -08:00
|
|
|
selectText(node: Node): 'error:notconnected' | 'done' {
|
2021-06-28 14:18:01 -07:00
|
|
|
const element = this.retarget(node, 'follow-label');
|
2021-02-10 12:36:26 -08:00
|
|
|
if (!element)
|
|
|
|
return 'error:notconnected';
|
2020-04-14 17:09:26 -07:00
|
|
|
if (element.nodeName.toLowerCase() === 'input') {
|
|
|
|
const input = element as HTMLInputElement;
|
2020-02-25 07:06:20 -08:00
|
|
|
input.select();
|
|
|
|
input.focus();
|
2020-06-23 13:02:31 -07:00
|
|
|
return 'done';
|
2020-04-07 10:07:06 -07:00
|
|
|
}
|
|
|
|
if (element.nodeName.toLowerCase() === 'textarea') {
|
2020-02-25 07:06:20 -08:00
|
|
|
const textarea = element as HTMLTextAreaElement;
|
|
|
|
textarea.selectionStart = 0;
|
|
|
|
textarea.selectionEnd = textarea.value.length;
|
|
|
|
textarea.focus();
|
2020-06-23 13:02:31 -07:00
|
|
|
return 'done';
|
2020-04-07 10:07:06 -07:00
|
|
|
}
|
2020-06-14 17:24:45 -07:00
|
|
|
const range = element.ownerDocument.createRange();
|
2020-04-14 17:09:26 -07:00
|
|
|
range.selectNodeContents(element);
|
2020-06-14 17:24:45 -07:00
|
|
|
const selection = element.ownerDocument.defaultView!.getSelection();
|
2021-02-10 12:36:26 -08:00
|
|
|
if (selection) {
|
|
|
|
selection.removeAllRanges();
|
|
|
|
selection.addRange(range);
|
|
|
|
}
|
2020-06-23 13:02:31 -07:00
|
|
|
(element as HTMLElement | SVGElement).focus();
|
|
|
|
return 'done';
|
|
|
|
}
|
|
|
|
|
2020-07-24 09:30:31 -07:00
|
|
|
focusNode(node: Node, resetSelectionIfNotFocused?: boolean): FatalDOMError | 'error:notconnected' | 'done' {
|
2020-04-18 18:29:31 -07:00
|
|
|
if (!node.isConnected)
|
2020-06-24 15:12:17 -07:00
|
|
|
return 'error:notconnected';
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
|
|
return 'error:notelement';
|
2020-07-24 09:30:31 -07:00
|
|
|
const wasFocused = (node.getRootNode() as (Document | ShadowRoot)).activeElement === node && node.ownerDocument && node.ownerDocument.hasFocus();
|
2020-04-18 18:29:31 -07:00
|
|
|
(node as HTMLElement | SVGElement).focus();
|
2020-07-24 09:30:31 -07:00
|
|
|
|
|
|
|
if (resetSelectionIfNotFocused && !wasFocused && node.nodeName.toLowerCase() === 'input') {
|
|
|
|
try {
|
|
|
|
const input = node as HTMLInputElement;
|
|
|
|
input.setSelectionRange(0, 0);
|
|
|
|
} catch (e) {
|
|
|
|
// Some inputs do not allow selection.
|
|
|
|
}
|
|
|
|
}
|
2020-06-24 15:12:17 -07:00
|
|
|
return 'done';
|
2020-02-25 07:06:20 -08:00
|
|
|
}
|
|
|
|
|
2020-09-03 10:09:03 -07:00
|
|
|
setInputFiles(node: Node, payloads: { name: string, mimeType: string, buffer: string }[]) {
|
2020-04-16 10:25:28 -07:00
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
|
|
return 'Node is not of type HTMLElement';
|
|
|
|
const element: Element | undefined = node as Element;
|
|
|
|
if (element.nodeName !== 'INPUT')
|
|
|
|
return 'Not an <input> element';
|
|
|
|
const input = element as HTMLInputElement;
|
|
|
|
const type = (input.getAttribute('type') || '').toLowerCase();
|
|
|
|
if (type !== 'file')
|
|
|
|
return 'Not an input[type=file] element';
|
|
|
|
|
2020-09-03 10:09:03 -07:00
|
|
|
const files = payloads.map(file => {
|
|
|
|
const bytes = Uint8Array.from(atob(file.buffer), c => c.charCodeAt(0));
|
|
|
|
return new File([bytes], file.name, { type: file.mimeType });
|
|
|
|
});
|
2020-04-16 10:25:28 -07:00
|
|
|
const dt = new DataTransfer();
|
|
|
|
for (const file of files)
|
|
|
|
dt.items.add(file);
|
|
|
|
input.files = dt.files;
|
|
|
|
input.dispatchEvent(new Event('input', { 'bubbles': true }));
|
|
|
|
input.dispatchEvent(new Event('change', { 'bubbles': true }));
|
|
|
|
}
|
|
|
|
|
2020-08-24 15:30:45 -07:00
|
|
|
checkHitTargetAt(node: Node, point: { x: number, y: number }): 'error:notconnected' | 'done' | { hitTargetDescription: string } {
|
2020-08-14 14:48:36 -07:00
|
|
|
let element: Element | null | undefined = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
2020-04-29 11:05:23 -07:00
|
|
|
if (!element || !element.isConnected)
|
2020-06-24 15:12:17 -07:00
|
|
|
return 'error:notconnected';
|
2020-05-19 16:27:56 -07:00
|
|
|
element = element.closest('button, [role=button]') || element;
|
2020-05-27 22:16:54 -07:00
|
|
|
let hitElement = this.deepElementFromPoint(document, point.x, point.y);
|
2020-08-14 14:48:36 -07:00
|
|
|
const hitParents: Element[] = [];
|
|
|
|
while (hitElement && hitElement !== element) {
|
|
|
|
hitParents.push(hitElement);
|
2020-12-06 15:03:36 -08:00
|
|
|
hitElement = parentElementOrShadowHost(hitElement);
|
2020-08-14 14:48:36 -07:00
|
|
|
}
|
|
|
|
if (hitElement === element)
|
|
|
|
return 'done';
|
|
|
|
const hitTargetDescription = this.previewNode(hitParents[0]);
|
|
|
|
// Root is the topmost element in the hitTarget's chain that is not in the
|
|
|
|
// element's chain. For example, it might be a dialog element that overlays
|
|
|
|
// the target.
|
|
|
|
let rootHitTargetDescription: string | undefined;
|
|
|
|
while (element) {
|
|
|
|
const index = hitParents.indexOf(element);
|
|
|
|
if (index !== -1) {
|
|
|
|
if (index > 1)
|
|
|
|
rootHitTargetDescription = this.previewNode(hitParents[index - 1]);
|
|
|
|
break;
|
|
|
|
}
|
2020-12-06 15:03:36 -08:00
|
|
|
element = parentElementOrShadowHost(element);
|
2020-08-14 14:48:36 -07:00
|
|
|
}
|
|
|
|
if (rootHitTargetDescription)
|
|
|
|
return { hitTargetDescription: `${hitTargetDescription} from ${rootHitTargetDescription} subtree` };
|
|
|
|
return { hitTargetDescription };
|
2020-02-25 07:06:20 -08:00
|
|
|
}
|
2020-03-25 14:08:46 -07:00
|
|
|
|
2020-04-23 14:58:37 -07:00
|
|
|
dispatchEvent(node: Node, type: string, eventInit: Object) {
|
|
|
|
let event;
|
|
|
|
eventInit = { bubbles: true, cancelable: true, composed: true, ...eventInit };
|
|
|
|
switch (eventType.get(type)) {
|
|
|
|
case 'mouse': event = new MouseEvent(type, eventInit); break;
|
|
|
|
case 'keyboard': event = new KeyboardEvent(type, eventInit); break;
|
|
|
|
case 'touch': event = new TouchEvent(type, eventInit); break;
|
|
|
|
case 'pointer': event = new PointerEvent(type, eventInit); break;
|
|
|
|
case 'focus': event = new FocusEvent(type, eventInit); break;
|
|
|
|
case 'drag': event = new DragEvent(type, eventInit); break;
|
|
|
|
default: event = new Event(type, eventInit); break;
|
|
|
|
}
|
|
|
|
node.dispatchEvent(event);
|
|
|
|
}
|
|
|
|
|
2020-05-27 22:16:54 -07:00
|
|
|
deepElementFromPoint(document: Document, x: number, y: number): Element | undefined {
|
2020-03-25 14:08:46 -07:00
|
|
|
let container: Document | ShadowRoot | null = document;
|
|
|
|
let element: Element | undefined;
|
|
|
|
while (container) {
|
2021-04-02 10:36:24 -07:00
|
|
|
// elementFromPoint works incorrectly in Chromium (http://crbug.com/1188919),
|
|
|
|
// so we use elementsFromPoint instead.
|
|
|
|
const elements = container.elementsFromPoint(x, y);
|
|
|
|
const innerElement = elements[0] as Element | undefined;
|
2020-03-25 14:08:46 -07:00
|
|
|
if (!innerElement || element === innerElement)
|
|
|
|
break;
|
|
|
|
element = innerElement;
|
|
|
|
container = element.shadowRoot;
|
|
|
|
}
|
|
|
|
return element;
|
|
|
|
}
|
2020-06-01 15:48:23 -07:00
|
|
|
|
2020-06-12 11:10:18 -07:00
|
|
|
previewNode(node: Node): string {
|
|
|
|
if (node.nodeType === Node.TEXT_NODE)
|
|
|
|
return oneLine(`#text=${node.nodeValue || ''}`);
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
|
|
return oneLine(`<${node.nodeName.toLowerCase()} />`);
|
|
|
|
const element = node as Element;
|
|
|
|
|
2020-06-06 20:59:06 -07:00
|
|
|
const attrs = [];
|
|
|
|
for (let i = 0; i < element.attributes.length; i++) {
|
2020-06-12 11:10:18 -07:00
|
|
|
const { name, value } = element.attributes[i];
|
|
|
|
if (name === 'style')
|
|
|
|
continue;
|
|
|
|
if (!value && booleanAttributes.has(name))
|
|
|
|
attrs.push(` ${name}`);
|
|
|
|
else
|
|
|
|
attrs.push(` ${name}="${value}"`);
|
2020-06-06 20:59:06 -07:00
|
|
|
}
|
|
|
|
attrs.sort((a, b) => a.length - b.length);
|
|
|
|
let attrText = attrs.join('');
|
|
|
|
if (attrText.length > 50)
|
|
|
|
attrText = attrText.substring(0, 49) + '\u2026';
|
|
|
|
if (autoClosingTags.has(element.nodeName))
|
2020-06-12 11:10:18 -07:00
|
|
|
return oneLine(`<${element.nodeName.toLowerCase()}${attrText}/>`);
|
2020-06-06 20:59:06 -07:00
|
|
|
|
|
|
|
const children = element.childNodes;
|
|
|
|
let onlyText = false;
|
|
|
|
if (children.length <= 5) {
|
|
|
|
onlyText = true;
|
|
|
|
for (let i = 0; i < children.length; i++)
|
|
|
|
onlyText = onlyText && children[i].nodeType === Node.TEXT_NODE;
|
|
|
|
}
|
2020-06-12 11:10:18 -07:00
|
|
|
let text = onlyText ? (element.textContent || '') : (children.length ? '\u2026' : '');
|
2020-06-06 20:59:06 -07:00
|
|
|
if (text.length > 50)
|
|
|
|
text = text.substring(0, 49) + '\u2026';
|
2020-06-12 11:10:18 -07:00
|
|
|
return oneLine(`<${element.nodeName.toLowerCase()}${attrText}>${text}</${element.nodeName.toLowerCase()}>`);
|
2020-06-01 15:48:23 -07:00
|
|
|
}
|
2019-11-21 14:43:30 -08:00
|
|
|
}
|
2020-04-23 14:58:37 -07:00
|
|
|
|
2020-06-06 20:59:06 -07:00
|
|
|
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
2020-06-12 11:10:18 -07:00
|
|
|
const booleanAttributes = new Set(['checked', 'selected', 'disabled', 'readonly', 'multiple']);
|
|
|
|
|
|
|
|
function oneLine(s: string): string {
|
|
|
|
return s.replace(/\n/g, '↵').replace(/\t/g, '⇆');
|
|
|
|
}
|
2020-06-06 20:59:06 -07:00
|
|
|
|
2020-04-23 14:58:37 -07:00
|
|
|
const eventType = new Map<string, 'mouse'|'keyboard'|'touch'|'pointer'|'focus'|'drag'>([
|
|
|
|
['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'],
|
|
|
|
]);
|
2020-08-24 15:30:45 -07:00
|
|
|
|
2021-02-04 17:44:55 -08:00
|
|
|
function unescape(s: string): string {
|
|
|
|
if (!s.includes('\\'))
|
|
|
|
return s;
|
|
|
|
const r: string[] = [];
|
|
|
|
let i = 0;
|
|
|
|
while (i < s.length) {
|
|
|
|
if (s[i] === '\\' && i + 1 < s.length)
|
|
|
|
i++;
|
|
|
|
r.push(s[i++]);
|
|
|
|
}
|
|
|
|
return r.join('');
|
|
|
|
}
|
|
|
|
|
2021-03-03 10:51:10 -08:00
|
|
|
function createTextMatcher(selector: string): { matcher: TextMatcher, kind: 'regex' | 'strict' | 'lax' } {
|
2021-02-04 17:44:55 -08:00
|
|
|
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
|
|
|
|
const lastSlash = selector.lastIndexOf('/');
|
2021-03-03 10:51:10 -08:00
|
|
|
const matcher: TextMatcher = createRegexTextMatcher(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
|
|
|
|
return { matcher, kind: 'regex' };
|
2021-02-04 17:44:55 -08:00
|
|
|
}
|
|
|
|
let strict = false;
|
|
|
|
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
|
|
|
|
selector = unescape(selector.substring(1, selector.length - 1));
|
|
|
|
strict = true;
|
|
|
|
}
|
|
|
|
if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
|
|
|
|
selector = unescape(selector.substring(1, selector.length - 1));
|
|
|
|
strict = true;
|
|
|
|
}
|
2021-03-03 10:51:10 -08:00
|
|
|
const matcher = strict ? createStrictTextMatcher(selector) : createLaxTextMatcher(selector);
|
|
|
|
return { matcher, kind: strict ? 'strict' : 'lax' };
|
2021-02-04 17:44:55 -08:00
|
|
|
}
|
|
|
|
|
2020-08-24 15:30:45 -07:00
|
|
|
export default InjectedScript;
|