Skip to content

Commit 99b7aaa

Browse files
authored
chore: refactor injected script harness (#2259)
1 parent 9c7e43a commit 99b7aaa

19 files changed

+186
-1199
lines changed

src/chromium/crExecutionContext.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
4949
}).catch(rewriteError);
5050
if (exceptionDetails)
5151
throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails));
52-
return returnByValue ? valueFromRemoteObject(remoteObject) : context._createHandle(remoteObject);
52+
return returnByValue ? valueFromRemoteObject(remoteObject) : context.createHandle(remoteObject);
5353
}
5454

5555
if (typeof pageFunction !== 'function')
@@ -91,7 +91,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
9191
}).catch(rewriteError);
9292
if (exceptionDetails)
9393
throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails));
94-
return returnByValue ? valueFromRemoteObject(remoteObject) : context._createHandle(remoteObject);
94+
return returnByValue ? valueFromRemoteObject(remoteObject) : context.createHandle(remoteObject);
9595
} finally {
9696
dispose();
9797
}
@@ -122,7 +122,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
122122
for (const property of response.result) {
123123
if (!property.enumerable)
124124
continue;
125-
result.set(property.name, handle._context._createHandle(property.value));
125+
result.set(property.name, handle._context.createHandle(property.value));
126126
}
127127
return result;
128128
}

src/chromium/crPage.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ class FrameSession {
577577
session.send('Runtime.runIfWaitingForDebugger'),
578578
]).catch(logError(this._page)); // This might fail if the target is closed before we initialize.
579579
session.on('Runtime.consoleAPICalled', event => {
580-
const args = event.args.map(o => worker._existingExecutionContext!._createHandle(o));
580+
const args = event.args.map(o => worker._existingExecutionContext!.createHandle(o));
581581
this._page._addConsoleMessage(event.type, args, toConsoleMessageLocation(event.stackTrace));
582582
});
583583
session.on('Runtime.exceptionThrown', exception => this._page.emit(Events.Page.PageError, exceptionToError(exception.exceptionDetails)));
@@ -608,7 +608,7 @@ class FrameSession {
608608
return;
609609
}
610610
const context = this._contextIdToContext.get(event.executionContextId)!;
611-
const values = event.args.map(arg => context._createHandle(arg));
611+
const values = event.args.map(arg => context.createHandle(arg));
612612
this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace));
613613
}
614614

@@ -846,7 +846,7 @@ class FrameSession {
846846
}).catch(logError(this._page));
847847
if (!result || result.object.subtype === 'null')
848848
throw new Error('Unable to adopt element handle from a different document');
849-
return to._createHandle(result.object).asElement()!;
849+
return to.createHandle(result.object).asElement()!;
850850
}
851851
}
852852

src/dom.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import * as path from 'path';
2020
import * as util from 'util';
2121
import * as frames from './frames';
2222
import { assert, helper } from './helper';
23-
import { Injected, InjectedResult } from './injected/injected';
23+
import InjectedScript from './injected/injectedScript';
24+
import * as injectedScriptSource from './generated/injectedScriptSource';
2425
import * as input from './input';
2526
import * as js from './javascript';
2627
import { Page } from './page';
@@ -52,29 +53,35 @@ export class FrameExecutionContext extends js.ExecutionContext {
5253
this.frame = frame;
5354
}
5455

55-
_adoptIfNeeded(handle: js.JSHandle): Promise<js.JSHandle> | null {
56+
adoptIfNeeded(handle: js.JSHandle): Promise<js.JSHandle> | null {
5657
if (handle instanceof ElementHandle && handle._context !== this)
5758
return this.frame._page._delegate.adoptElementHandle(handle, this);
5859
return null;
5960
}
6061

61-
async _doEvaluateInternal(returnByValue: boolean, waitForNavigations: boolean, pageFunction: string | Function, ...args: any[]): Promise<any> {
62+
async doEvaluateInternal(returnByValue: boolean, waitForNavigations: boolean, pageFunction: string | Function, ...args: any[]): Promise<any> {
6263
return await this.frame._page._frameManager.waitForSignalsCreatedBy(async () => {
6364
return this._delegate.evaluate(this, returnByValue, pageFunction, ...args);
6465
}, Number.MAX_SAFE_INTEGER, waitForNavigations ? undefined : { noWaitAfter: true });
6566
}
6667

67-
_createHandle(remoteObject: any): js.JSHandle {
68+
createHandle(remoteObject: any): js.JSHandle {
6869
if (this.frame._page._delegate.isElementHandle(remoteObject))
6970
return new ElementHandle(this, remoteObject);
70-
return super._createHandle(remoteObject);
71+
return super.createHandle(remoteObject);
7172
}
7273

73-
_injected(): Promise<js.JSHandle<Injected>> {
74+
injectedScript(): Promise<js.JSHandle<InjectedScript>> {
7475
if (!this._injectedPromise) {
75-
this._injectedPromise = selectors._prepareEvaluator(this).then(evaluator => {
76-
return this.evaluateHandleInternal(evaluator => evaluator.injected, evaluator);
77-
});
76+
const custom: string[] = [];
77+
for (const [name, { source }] of selectors._engines)
78+
custom.push(`{ name: '${name}', engine: (${source}) }`);
79+
const source = `
80+
new (${injectedScriptSource.source})([
81+
${custom.join(',\n')}
82+
])
83+
`;
84+
this._injectedPromise = this.doEvaluateInternal(false /* returnByValue */, false /* waitForNavigations */, source);
7885
}
7986
return this._injectedPromise;
8087
}
@@ -94,14 +101,14 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
94101
return this;
95102
}
96103

97-
async _evaluateInMain<R, Arg>(pageFunction: types.FuncOn<{ injected: Injected, node: T }, Arg, R>, arg: Arg): Promise<R> {
104+
async _evaluateInMain<R, Arg>(pageFunction: types.FuncOn<{ injected: InjectedScript, node: T }, Arg, R>, arg: Arg): Promise<R> {
98105
const main = await this._context.frame._mainContext();
99-
return main._doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await main._injected(), node: this }, arg);
106+
return main.doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await main.injectedScript(), node: this }, arg);
100107
}
101108

102-
async _evaluateInUtility<R, Arg>(pageFunction: types.FuncOn<{ injected: Injected, node: T }, Arg, R>, arg: Arg): Promise<R> {
109+
async _evaluateInUtility<R, Arg>(pageFunction: types.FuncOn<{ injected: InjectedScript, node: T }, Arg, R>, arg: Arg): Promise<R> {
103110
const utility = await this._context.frame._utilityContext();
104-
return utility._doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await utility._injected(), node: this }, arg);
111+
return utility.doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await utility.injectedScript(), node: this }, arg);
105112
}
106113

107114
async ownerFrame(): Promise<frames.Frame | null> {
@@ -352,7 +359,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
352359
async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options?: types.NavigatingActionWaitOptions) {
353360
this._page._log(inputLog, `elementHandle.setInputFiles(...)`);
354361
const deadline = this._page._timeoutSettings.computeDeadline(options);
355-
const injectedResult = await this._evaluateInUtility(({ node }): InjectedResult<boolean> => {
362+
const injectedResult = await this._evaluateInUtility(({ node }): types.InjectedScriptResult<boolean> => {
356363
if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT')
357364
return { status: 'error', error: 'Node is not an HTMLInputElement' };
358365
if (!node.isConnected)
@@ -500,7 +507,7 @@ export function toFileTransferPayload(files: types.FilePayload[]): types.FileTra
500507
}));
501508
}
502509

503-
function handleInjectedResult<T = undefined>(injectedResult: InjectedResult<T>, timeoutMessage: string): T {
510+
function handleInjectedResult<T = undefined>(injectedResult: types.InjectedScriptResult<T>, timeoutMessage: string): T {
504511
if (injectedResult.status === 'notconnected')
505512
throw new NotConnectedError();
506513
if (injectedResult.status === 'timeout')

src/firefox/ffExecutionContext.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
3939
checkException(payload.exceptionDetails);
4040
if (returnByValue)
4141
return deserializeValue(payload.result!);
42-
return context._createHandle(payload.result);
42+
return context.createHandle(payload.result);
4343
}
4444
if (typeof pageFunction !== 'function')
4545
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`);
@@ -71,7 +71,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
7171
checkException(payload.exceptionDetails);
7272
if (returnByValue)
7373
return deserializeValue(payload.result!);
74-
return context._createHandle(payload.result);
74+
return context.createHandle(payload.result);
7575
} finally {
7676
dispose();
7777
}
@@ -97,7 +97,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
9797
});
9898
const result = new Map();
9999
for (const property of response.properties)
100-
result.set(property.name, handle._context._createHandle(property.value));
100+
result.set(property.name, handle._context.createHandle(property.value));
101101
return result;
102102
}
103103

src/firefox/ffPage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export class FFPage implements PageDelegate {
184184
_onConsole(payload: Protocol.Runtime.consolePayload) {
185185
const {type, args, executionContextId, location} = payload;
186186
const context = this._contextIdToContext.get(executionContextId)!;
187-
this._page._addConsoleMessage(type, args.map(arg => context._createHandle(arg)), location);
187+
this._page._addConsoleMessage(type, args.map(arg => context.createHandle(arg)), location);
188188
}
189189

190190
_onDialogOpened(params: Protocol.Page.dialogOpenedPayload) {
@@ -205,7 +205,7 @@ export class FFPage implements PageDelegate {
205205
async _onFileChooserOpened(payload: Protocol.Page.fileChooserOpenedPayload) {
206206
const {executionContextId, element} = payload;
207207
const context = this._contextIdToContext.get(executionContextId)!;
208-
const handle = context._createHandle(element).asElement()!;
208+
const handle = context.createHandle(element).asElement()!;
209209
this._page._onFileChooserOpened(handle);
210210
}
211211

@@ -229,7 +229,7 @@ export class FFPage implements PageDelegate {
229229
workerSession.on('Runtime.console', event => {
230230
const {type, args, location} = event;
231231
const context = worker._existingExecutionContext!;
232-
this._page._addConsoleMessage(type, args.map(arg => context._createHandle(arg)), location);
232+
this._page._addConsoleMessage(type, args.map(arg => context.createHandle(arg)), location);
233233
});
234234
// Note: we receive worker exceptions directly from the page.
235235
}
@@ -457,7 +457,7 @@ export class FFPage implements PageDelegate {
457457
});
458458
if (!result.remoteObject)
459459
throw new Error('Unable to adopt element handle from a different document');
460-
return to._createHandle(result.remoteObject) as dom.ElementHandle<T>;
460+
return to.createHandle(result.remoteObject) as dom.ElementHandle<T>;
461461
}
462462

463463
async getAccessibilityTree(needle?: dom.ElementHandle) {

src/frames.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,7 @@ export class Frame {
786786
const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, predicateBody, polling, timeout, arg }) => {
787787
const innerPredicate = new Function('arg', predicateBody);
788788
return injected.poll(polling, timeout, () => innerPredicate(arg));
789-
}, { injected: await context._injected(), predicateBody, polling, timeout: helper.timeUntilDeadline(deadline), arg });
789+
}, { injected: await context.injectedScript(), predicateBody, polling, timeout: helper.timeUntilDeadline(deadline), arg });
790790
return this._scheduleRerunnableTask(task, 'main', deadline) as any as types.SmartHandle<R>;
791791
}
792792

src/injected/injected.ts renamed to src/injected/injectedScript.ts

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,83 @@
1515
*/
1616

1717
import * as types from '../types';
18+
import { createAttributeEngine } from './attributeSelectorEngine';
19+
import { createCSSEngine } from './cssSelectorEngine';
20+
import { SelectorEngine, SelectorRoot } from './selectorEngine';
21+
import { createTextSelector } from './textSelectorEngine';
22+
import { XPathEngine } from './xpathSelectorEngine';
1823

1924
type Predicate<T> = () => T;
20-
export type InjectedResult<T = undefined> =
21-
(T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) |
22-
{ status: 'notconnected' } |
23-
{ status: 'timeout' } |
24-
{ status: 'error', error: string };
2525

26-
export class Injected {
26+
export default class InjectedScript {
27+
readonly engines: Map<string, SelectorEngine>;
28+
29+
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
30+
this.engines = new Map();
31+
// Note: keep predefined names in sync with Selectors class.
32+
this.engines.set('css', createCSSEngine(true));
33+
this.engines.set('css:light', createCSSEngine(false));
34+
this.engines.set('xpath', XPathEngine);
35+
this.engines.set('xpath:light', XPathEngine);
36+
this.engines.set('text', createTextSelector(true));
37+
this.engines.set('text:light', createTextSelector(false));
38+
this.engines.set('id', createAttributeEngine('id', true));
39+
this.engines.set('id:light', createAttributeEngine('id', false));
40+
this.engines.set('data-testid', createAttributeEngine('data-testid', true));
41+
this.engines.set('data-testid:light', createAttributeEngine('data-testid', false));
42+
this.engines.set('data-test-id', createAttributeEngine('data-test-id', true));
43+
this.engines.set('data-test-id:light', createAttributeEngine('data-test-id', false));
44+
this.engines.set('data-test', createAttributeEngine('data-test', true));
45+
this.engines.set('data-test:light', createAttributeEngine('data-test', false));
46+
for (const {name, engine} of customEngines)
47+
this.engines.set(name, engine);
48+
}
49+
50+
querySelector(selector: types.ParsedSelector, root: Node): Element | undefined {
51+
if (!(root as any)['querySelector'])
52+
throw new Error('Node is not queryable.');
53+
return this._querySelectorRecursively(root as SelectorRoot, selector, 0);
54+
}
55+
56+
private _querySelectorRecursively(root: SelectorRoot, selector: types.ParsedSelector, index: number): Element | undefined {
57+
const current = selector.parts[index];
58+
if (index === selector.parts.length - 1)
59+
return this.engines.get(current.name)!.query(root, current.body);
60+
const all = this.engines.get(current.name)!.queryAll(root, current.body);
61+
for (const next of all) {
62+
const result = this._querySelectorRecursively(next, selector, index + 1);
63+
if (result)
64+
return selector.capture === index ? next : result;
65+
}
66+
}
67+
68+
querySelectorAll(selector: types.ParsedSelector, root: Node): Element[] {
69+
if (!(root as any)['querySelectorAll'])
70+
throw new Error('Node is not queryable.');
71+
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
72+
// Query all elements up to the capture.
73+
const partsToQuerAll = selector.parts.slice(0, capture + 1);
74+
// Check they have a descendant matching everything after the capture.
75+
const partsToCheckOne = selector.parts.slice(capture + 1);
76+
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
77+
for (const { name, body } of partsToQuerAll) {
78+
const newSet = new Set<Element>();
79+
for (const prev of set) {
80+
for (const next of this.engines.get(name)!.queryAll(prev, body)) {
81+
if (newSet.has(next))
82+
continue;
83+
newSet.add(next);
84+
}
85+
}
86+
set = newSet;
87+
}
88+
const candidates = Array.from(set) as Element[];
89+
if (!partsToCheckOne.length)
90+
return candidates;
91+
const partial = { parts: partsToCheckOne };
92+
return candidates.filter(e => !!this._querySelectorRecursively(e, partial, 0));
93+
}
94+
2795
isVisible(element: Element): boolean {
2896
// Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises.
2997
if (!element.ownerDocument || !element.ownerDocument.defaultView)
@@ -95,7 +163,7 @@ export class Injected {
95163
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
96164
}
97165

98-
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): InjectedResult<string[]> {
166+
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): types.InjectedScriptResult<string[]> {
99167
if (node.nodeName.toLowerCase() !== 'select')
100168
return { status: 'error', error: 'Element is not a <select> element.' };
101169
if (!node.isConnected)
@@ -126,7 +194,7 @@ export class Injected {
126194
return { status: 'success', value: options.filter(option => option.selected).map(option => option.value) };
127195
}
128196

129-
fill(node: Node, value: string): InjectedResult<boolean> {
197+
fill(node: Node, value: string): types.InjectedScriptResult<boolean> {
130198
if (node.nodeType !== Node.ELEMENT_NODE)
131199
return { status: 'error', error: 'Node is not of type HTMLElement' };
132200
const element = node as HTMLElement;
@@ -175,7 +243,7 @@ export class Injected {
175243
return result;
176244
}
177245

178-
selectText(node: Node): InjectedResult {
246+
selectText(node: Node): types.InjectedScriptResult {
179247
if (node.nodeType !== Node.ELEMENT_NODE)
180248
return { status: 'error', error: 'Node is not of type HTMLElement' };
181249
if (!node.isConnected)
@@ -207,7 +275,7 @@ export class Injected {
207275
return { status: 'success' };
208276
}
209277

210-
focusNode(node: Node): InjectedResult {
278+
focusNode(node: Node): types.InjectedScriptResult {
211279
if (!node.isConnected)
212280
return { status: 'notconnected' };
213281
if (!(node as any)['focus'])
@@ -262,7 +330,7 @@ export class Injected {
262330
input.dispatchEvent(new Event('change', { 'bubbles': true }));
263331
}
264332

265-
async waitForDisplayedAtStablePosition(node: Node, rafCount: number, timeout: number): Promise<InjectedResult> {
333+
async waitForDisplayedAtStablePosition(node: Node, rafCount: number, timeout: number): Promise<types.InjectedScriptResult> {
266334
if (!node.isConnected)
267335
return { status: 'notconnected' };
268336
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
@@ -305,7 +373,7 @@ export class Injected {
305373
return { status: result === 'notconnected' ? 'notconnected' : (result ? 'success' : 'timeout') };
306374
}
307375

308-
checkHitTargetAt(node: Node, point: types.Point): InjectedResult<boolean> {
376+
checkHitTargetAt(node: Node, point: types.Point): types.InjectedScriptResult<boolean> {
309377
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
310378
while (element && window.getComputedStyle(element).pointerEvents === 'none')
311379
element = element.parentElement;

src/injected/selectorEvaluator.webpack.config.js renamed to src/injected/injectedScript.webpack.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const path = require('path');
1818
const InlineSource = require('./webpack-inline-source-plugin.js');
1919

2020
module.exports = {
21-
entry: path.join(__dirname, 'selectorEvaluator.ts'),
21+
entry: path.join(__dirname, 'injectedScript.ts'),
2222
devtool: 'source-map',
2323
module: {
2424
rules: [
@@ -36,10 +36,10 @@ module.exports = {
3636
extensions: [ '.tsx', '.ts', '.js' ]
3737
},
3838
output: {
39-
filename: 'selectorEvaluatorSource.js',
39+
filename: 'injectedScriptSource.js',
4040
path: path.resolve(__dirname, '../../lib/injected/packed')
4141
},
4242
plugins: [
43-
new InlineSource(path.join(__dirname, '..', 'generated', 'selectorEvaluatorSource.ts')),
43+
new InlineSource(path.join(__dirname, '..', 'generated', 'injectedScriptSource.ts')),
4444
]
4545
};

0 commit comments

Comments
 (0)