Skip to content

Commit

Permalink
proxyless: speed up 'typeText' action (#7536)
Browse files Browse the repository at this point in the history
Run a simple test 50 times.
```js
fixture `TypeText speed up test suite`
    .page('https://devexpress.github.io/testcafe/example/');

for (let i = 0; i < 50; i++) {
    test('test', async t => {
        await t.typeText('#developer-name', 'Peter Parker');
    });
}
```

*Execution time (from fastest):*
* improved proxyless mode (this PR) - 41s.
* regular mode - 42s. 
* current proxyless mode (master) - 51s.
  • Loading branch information
miherlosev authored Feb 24, 2023
1 parent 6967ab8 commit 5b776fe
Show file tree
Hide file tree
Showing 20 changed files with 249 additions and 136 deletions.
14 changes: 14 additions & 0 deletions src/browser/connection/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default class BrowserConnectionGateway {
this._dispatch(`${SERVICE_ROUTES.closeWindow}/{id}`, proxy, BrowserConnectionGateway._onCloseWindowRequest, 'POST');
this._dispatch(`${SERVICE_ROUTES.openFileProtocol}/{id}`, proxy, BrowserConnectionGateway._onOpenFileProtocolRequest, 'POST');
this._dispatch(`${SERVICE_ROUTES.dispatchProxylessEvent}/{id}`, proxy, BrowserConnectionGateway._onDispatchProxylessEvent, 'POST', this.proxyless);
this._dispatch(`${SERVICE_ROUTES.dispatchProxylessEventSequence}/{id}`, proxy, BrowserConnectionGateway._onDispatchProxylessEventSequence, 'POST', this.proxyless);
this._dispatch(`${SERVICE_ROUTES.parseSelector}/{id}`, proxy, BrowserConnectionGateway._parseSelector, 'POST');

proxy.GET(SERVICE_ROUTES.connect, (req: IncomingMessage, res: ServerResponse) => this._connectNextRemoteBrowser(req, res));
Expand Down Expand Up @@ -237,6 +238,19 @@ export default class BrowserConnectionGateway {
}
}

private static _onDispatchProxylessEventSequence (req: IncomingMessage, res: ServerResponse, connection: BrowserConnection): void {
if (BrowserConnectionGateway._ensureConnectionReady(res, connection)) {
BrowserConnectionGateway._fetchRequestData(req, data => {
const eventSequence = JSON.parse(data);

connection.dispatchProxylessEventSequence(eventSequence)
.then(() => {
respondWithJSON(res);
});
});
}
}

private async _connectNextRemoteBrowser (req: IncomingMessage, res: ServerResponse): Promise<void> {
preventCaching(res);

Expand Down
24 changes: 15 additions & 9 deletions src/browser/connection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export default class BrowserConnection extends EventEmitter {
public openFileProtocolRelativeUrl = '';
public openFileProtocolUrl = '';
public dispatchProxylessEventRelativeUrl = '';
public dispatchProxylessEventSequenceRelativeUrl = '';
public parseSelectorRelativeUrl = '';
private readonly debugLogger: debug.Debugger;
private osInfo: OSInfo | null = null;
Expand Down Expand Up @@ -188,15 +189,16 @@ export default class BrowserConnection extends EventEmitter {
this.forcedIdleUrl = proxy.resolveRelativeServiceUrl(`${SERVICE_ROUTES.idleForced}/${this.id}`);
this.initScriptUrl = proxy.resolveRelativeServiceUrl(`${SERVICE_ROUTES.initScript}/${this.id}`);

this.heartbeatRelativeUrl = `${SERVICE_ROUTES.heartbeat}/${this.id}`;
this.statusRelativeUrl = `${SERVICE_ROUTES.status}/${this.id}`;
this.statusDoneRelativeUrl = `${SERVICE_ROUTES.statusDone}/${this.id}`;
this.idleRelativeUrl = `${SERVICE_ROUTES.idle}/${this.id}`;
this.activeWindowIdUrl = `${SERVICE_ROUTES.activeWindowId}/${this.id}`;
this.closeWindowUrl = `${SERVICE_ROUTES.closeWindow}/${this.id}`;
this.openFileProtocolRelativeUrl = `${SERVICE_ROUTES.openFileProtocol}/${this.id}`;
this.dispatchProxylessEventRelativeUrl = `${SERVICE_ROUTES.dispatchProxylessEvent}/${this.id}`;
this.parseSelectorRelativeUrl = `${SERVICE_ROUTES.parseSelector}/${this.id}`;
this.heartbeatRelativeUrl = `${SERVICE_ROUTES.heartbeat}/${this.id}`;
this.statusRelativeUrl = `${SERVICE_ROUTES.status}/${this.id}`;
this.statusDoneRelativeUrl = `${SERVICE_ROUTES.statusDone}/${this.id}`;
this.idleRelativeUrl = `${SERVICE_ROUTES.idle}/${this.id}`;
this.activeWindowIdUrl = `${SERVICE_ROUTES.activeWindowId}/${this.id}`;
this.closeWindowUrl = `${SERVICE_ROUTES.closeWindow}/${this.id}`;
this.openFileProtocolRelativeUrl = `${SERVICE_ROUTES.openFileProtocol}/${this.id}`;
this.dispatchProxylessEventRelativeUrl = `${SERVICE_ROUTES.dispatchProxylessEvent}/${this.id}`;
this.dispatchProxylessEventSequenceRelativeUrl = `${SERVICE_ROUTES.dispatchProxylessEventSequence}/${this.id}`;
this.parseSelectorRelativeUrl = `${SERVICE_ROUTES.parseSelector}/${this.id}`;

this.idleUrl = proxy.resolveRelativeServiceUrl(this.idleRelativeUrl);
this.heartbeatUrl = proxy.resolveRelativeServiceUrl(this.heartbeatRelativeUrl);
Expand Down Expand Up @@ -622,6 +624,10 @@ export default class BrowserConnection extends EventEmitter {
return this.provider.dispatchProxylessEvent(this.id, type, options);
}

public async dispatchProxylessEventSequence (eventSequence: []): Promise<void> {
return this.provider.dispatchProxylessEventSequence(this.id, eventSequence);
}

public async canUseDefaultWindowActions (): Promise<boolean> {
return this.provider.canUseDefaultWindowActions(this.id);
}
Expand Down
29 changes: 15 additions & 14 deletions src/browser/connection/service-routes.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
export default {
connect: '/browser/connect',
connectWithTrailingSlash: '/browser/connect/',
heartbeat: '/browser/heartbeat',
status: '/browser/status',
statusDone: '/browser/status-done',
initScript: '/browser/init-script',
idle: '/browser/idle',
idleForced: '/browser/idle-forced',
activeWindowId: '/browser/active-window-id',
closeWindow: '/browser/close-window',
serviceWorker: '/service-worker.js',
openFileProtocol: '/browser/open-file-protocol',
dispatchProxylessEvent: '/browser/dispatch-proxyless-event',
parseSelector: '/parse-selector',
connect: '/browser/connect',
connectWithTrailingSlash: '/browser/connect/',
heartbeat: '/browser/heartbeat',
status: '/browser/status',
statusDone: '/browser/status-done',
initScript: '/browser/init-script',
idle: '/browser/idle',
idleForced: '/browser/idle-forced',
activeWindowId: '/browser/active-window-id',
closeWindow: '/browser/close-window',
serviceWorker: '/service-worker.js',
openFileProtocol: '/browser/open-file-protocol',
dispatchProxylessEvent: '/browser/dispatch-proxyless-event',
dispatchProxylessEventSequence: '/browser/dispatch-proxyless-event-sequence',
parseSelector: '/parse-selector',

assets: {
index: '/browser/assets/index.js',
Expand Down
26 changes: 22 additions & 4 deletions src/browser/provider/built-in/dedicated/chrome/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { BrowserClient } from './cdp-client';
import { dispatchEvent as dispatchProxylessEvent, navigateTo } from '../../../../../proxyless/utils/cdp';
import Proxyless from '../../../../../proxyless';
import { chromeBrowserProviderLogger } from '../../../../../utils/debug-loggers';
import { EventType } from '../../../../../proxyless/types';
import delay from '../../../../../utils/delay';

const MIN_AVAILABLE_DIMENSION = 50;

Expand All @@ -23,6 +25,13 @@ export default {
return getConfig(name);
},

async _getActiveCDPClient (browserId) {
const { browserClient } = this.openedBrowsers[browserId];
const cdpClient = await browserClient.getActiveClient();

return cdpClient;
},

_getBrowserProtocolClient (runtimeInfo) {
return runtimeInfo.browserClient;
},
Expand Down Expand Up @@ -176,16 +185,25 @@ export default {
},

async openFileProtocol (browserId, url) {
const { browserClient } = this.openedBrowsers[browserId];
const cdpClient = await browserClient.getActiveClient();
const cdpClient = await this._getActiveCDPClient(browserId);

await navigateTo(cdpClient, url);
},

async dispatchProxylessEvent (browserId, type, options) {
const { browserClient } = this.openedBrowsers[browserId];
const cdpClient = await browserClient.getActiveClient();
const cdpClient = await this._getActiveCDPClient(browserId);

await dispatchProxylessEvent(cdpClient, type, options);
},

async dispatchProxylessEventSequence (browserId, eventSequence) {
const cdpClient = await this._getActiveCDPClient(browserId);

for (const event of eventSequence) {
if (event.type === EventType.Delay)
await delay(event.options.delay);
else
await dispatchProxylessEvent(cdpClient, event.type, event.options);
}
},
};
4 changes: 4 additions & 0 deletions src/browser/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,8 @@ export default class BrowserProvider {
public async dispatchProxylessEvent (browserId: string, type: EventType, options: any): Promise<void> {
await this.plugin.dispatchProxylessEvent(browserId, type, options);
}

public async dispatchProxylessEventSequence (browserId: string, sequence: []): Promise<void> {
await this.plugin.dispatchProxylessEventSequence(browserId, sequence);
}
}
3 changes: 2 additions & 1 deletion src/client/automation/playback/click/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createMouseClickStrategy, MouseClickStrategy } from './browser-click-st
import ProxylessInput from '../../../../proxyless/client/input';
import AxisValues from '../../../core/utils/values/axis-values';
import { setCaretPosition } from '../../utils/utils';
import { DispatchEventFn } from '../../../../proxyless/client/types';

export interface MouseClickEventState {
mousedownPrevented: boolean;
Expand All @@ -22,7 +23,7 @@ export default class ClickAutomation extends VisibleElementAutomation {
private modifiers: Modifiers;
public strategy: MouseClickStrategy;

protected constructor (element: HTMLElement, clickOptions: ClickOptions, win: Window, cursor: Cursor, dispatchProxylessEventFn?: Function, leftTopPoint?: AxisValues<number>) {
protected constructor (element: HTMLElement, clickOptions: ClickOptions, win: Window, cursor: Cursor, dispatchProxylessEventFn?: DispatchEventFn, leftTopPoint?: AxisValues<number>) {
super(element, clickOptions, win, cursor, dispatchProxylessEventFn, leftTopPoint);

this.modifiers = clickOptions.modifiers;
Expand Down
56 changes: 38 additions & 18 deletions src/client/automation/playback/type/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import getKeyProperties from '../../utils/get-key-properties';
import cursor from '../../cursor';
import ProxylessInput from '../../../../proxyless/client/input';
import getKeyInfo from '../press/get-key-info';
import { EventType } from '../../../../proxyless/types';

const Promise = hammerhead.Promise;
const extend = hammerhead.utils.extend;
Expand Down Expand Up @@ -174,30 +175,51 @@ export default class TypeAutomation {
textSelection.deleteSelectionContents(this.element, true);
}

if (this._canUseProxylessInput()) {
const eventSequence = this._calculateCDPEventSequence();

return this.proxylessInput.executeEventSequence(eventSequence);
}

return promiseUtils.whilst(() => !this._isTypingFinished(), () => this._typingStep());
}

_isTypingFinished () {
return this.currentPos === this.typingText.length;
}

_proxylessTypingStep () {
const keyInfo = getKeyInfo(this.currentKey);

const simulatedKeyInfo = extend({ key: this.currentKey }, keyInfo);

return this.proxylessInput.keyDown(simulatedKeyInfo)
.then(() => {
return this.proxylessInput.keyUp(simulatedKeyInfo);
})
.then(() => {
this.currentPos++;
_getCurrentKey (keyCode, char) {
return keyCode === SPECIAL_KEYS['enter'] ? 'Enter' : char;
}

return delay(this.automationSettings.keyActionStepDelay);
_calculateCDPEventSequence () {
const eventSequence = [];

for (const char of this.typingText) {
const currentKeyCode = getKeyCode(char);
const currentKey = this._getCurrentKey(currentKeyCode, char);
const keyInfo = getKeyInfo(currentKey);
const simulatedKeyInfo = extend({ key: currentKey }, keyInfo);

eventSequence.push({
type: EventType.Keyboard,
options: this.proxylessInput.createKeyDownOptions(simulatedKeyInfo),
}, {
type: EventType.Keyboard,
options: this.proxylessInput.createKeyUpOptions(simulatedKeyInfo),
}, {
type: EventType.Delay,
options: { delay: this.automationSettings.keyActionStepDelay },
});
}

return eventSequence;
}

_canUseProxylessInput () {
if (!this.proxylessInput)
return false;

// NOTE: 'paste' is a synthetic option that don't have equivalent for native user action.
if (this.paste)
return false;
Expand All @@ -212,7 +234,7 @@ export default class TypeAutomation {
return true;
}

_regularTypingStep () {
_performTypingStep () {
this._keydown();
this._keypress();

Expand All @@ -224,15 +246,12 @@ export default class TypeAutomation {

this.currentKeyCode = getKeyCode(char);
this.currentCharCode = this.typingText.charCodeAt(this.currentPos);
this.currentKey = this.currentKeyCode === SPECIAL_KEYS['enter'] ? 'Enter' : char;
this.currentKey = this._getCurrentKey(this.currentKeyCode, char);
this.currentKeyIdentifier = getKeyIdentifier(this.currentKey);

this.ignoreChangeEvent = domUtils.getElementValue(this.element) === elementEditingWatcher.getElementSavedValue(this.element);

if (this.proxylessInput && this._canUseProxylessInput())
return this._proxylessTypingStep();

return this._regularTypingStep();
return this._performTypingStep();
}

_keydown () {
Expand Down Expand Up @@ -314,6 +333,7 @@ export default class TypeAutomation {

_typeAllText (element) {
typeText(element, this.typingText, this.caretPos);

return delay(this.automationSettings.keyActionStepDelay);
}

Expand Down
3 changes: 2 additions & 1 deletion src/client/automation/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ActionCommandBase } from '../../test-run/commands/base';
import EventEmitter from '../core/utils/event-emitter';
import AxisValues, { AxisValuesData } from '../core/utils/values/axis-values';
import { DispatchEventFn } from '../../proxyless/client/types';

export interface AutomationHandler {
create: (cmd: ActionCommandBase, elements: any[], dispatchProxylessEventFn?: Function, leftTopPoint?: AxisValues<number>) => Automation;
create: (cmd: ActionCommandBase, elements: any[], dispatchProxylessEventFn?: DispatchEventFn, leftTopPoint?: AxisValues<number>) => Automation;
ensureElsProps?: (elements: any[]) => void;
ensureCmdArgs?: (cmd: ActionCommandBase) => void;
additionalSelectorProps?: string[];
Expand Down
3 changes: 2 additions & 1 deletion src/client/automation/visible-element-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Dictionary } from '../../configuration/interfaces';
import ensureMouseEventAfterScroll from './utils/ensure-mouse-event-after-scroll';
import WARNING_TYPES from '../../shared/warnings/types';
import ProxylessInput from '../../proxyless/client/input';
import { DispatchEventFn } from '../../proxyless/client/types';


const AVAILABLE_OFFSET_DEEP = 2;
Expand Down Expand Up @@ -100,7 +101,7 @@ export default class VisibleElementAutomation extends SharedEventEmitter {
protected readonly options: OffsetOptions;
protected readonly proxylessInput: ProxylessInput | null;

protected constructor (element: HTMLElement, offsetOptions: OffsetOptions, win: Window, cursor: Cursor, dispatchProxylessEventFn?: Function, topLeftPoint?: AxisValues<number>) {
protected constructor (element: HTMLElement, offsetOptions: OffsetOptions, win: Window, cursor: Cursor, dispatchProxylessEventFn?: DispatchEventFn, topLeftPoint?: AxisValues<number>) {
super();

this.TARGET_ELEMENT_FOUND_EVENT = 'automation|target-element-found-event';
Expand Down
11 changes: 11 additions & 0 deletions src/client/browser/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,17 @@ export async function dispatchProxylessEvent (dispatchProxylessEventUrl, testCaf
await testCafeUI.show();
}

export async function dispatchProxylessEventSequence (dispatchProxylessEventSequenceUrl, testCafeUI, createXHR, sequence) {
await testCafeUI.hide();

await sendXHR(dispatchProxylessEventSequenceUrl, createXHR, {
method: 'POST',
data: JSON.stringify(sequence), //eslint-disable-line no-restricted-globals
});

await testCafeUI.show();
}

export function parseSelector (parseSelectorUrl, createXHR, selector) {
return sendXHR(parseSelectorUrl, createXHR, {
method: 'POST',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import COMMAND_TYPE from '../../../../test-run/commands/type';
import { ActionCommandBase } from '../../../../test-run/commands/base';
import { Automation } from '../../../automation/types';
import AxisValues from '../../../core/utils/values/axis-values';
import { DispatchEventFn } from '../../../../proxyless/client/types';


ActionExecutor.ACTIONS_HANDLERS[COMMAND_TYPE.dispatchEvent] = {
Expand All @@ -54,7 +55,7 @@ ActionExecutor.ACTIONS_HANDLERS[COMMAND_TYPE.dispatchEvent] = {
};

ActionExecutor.ACTIONS_HANDLERS[COMMAND_TYPE.pressKey] = {
create: (command, [], dispatchProxylessEventFn?: Function) => new PressAutomation(parseKeySequence(command.keys).combinations, command.options, dispatchProxylessEventFn), // eslint-disable-line no-empty-pattern
create: (command, [], dispatchProxylessEventFn?: DispatchEventFn) => new PressAutomation(parseKeySequence(command.keys).combinations, command.options, dispatchProxylessEventFn), // eslint-disable-line no-empty-pattern

ensureCmdArgs: command => {
const parsedKeySequence = parseKeySequence(command.keys);
Expand All @@ -65,7 +66,7 @@ ActionExecutor.ACTIONS_HANDLERS[COMMAND_TYPE.pressKey] = {
};

ActionExecutor.ACTIONS_HANDLERS[COMMAND_TYPE.click] = {
create: (command, elements, dispatchProxylessEventFn?: Function, leftTopPoint?: AxisValues<number>) => {
create: (command, elements, dispatchProxylessEventFn?: DispatchEventFn, leftTopPoint?: AxisValues<number>) => {
if (/option|optgroup/.test(domUtils.getTagName(elements[0])))
return new SelectChildClickAutomation(elements[0], command.options);

Expand Down Expand Up @@ -120,7 +121,7 @@ ActionExecutor.ACTIONS_HANDLERS[COMMAND_TYPE.scrollIntoView] = {

ActionExecutor.ACTIONS_HANDLERS[COMMAND_TYPE.typeText] = {
// eslint-disable-next-line no-restricted-properties
create: (command, elements, dispatchProxylessEventFn?: Function) => new TypeAutomation(elements[0], command.text, command.options, dispatchProxylessEventFn),
create: (command, elements, dispatchProxylessEventFn?: DispatchEventFn) => new TypeAutomation(elements[0], command.text, command.options, dispatchProxylessEventFn),
};


Expand Down
3 changes: 2 additions & 1 deletion src/client/driver/command-executors/action-executor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { nativeMethods, Promise } from '../../deps/hammerhead';
import { getOffsetOptions } from '../../../core/utils/offsets';
import { TEST_RUN_ERRORS } from '../../../../errors/types';
import AxisValues from '../../../core/utils/values/axis-values';
import { DispatchEventFn } from '../../../../proxyless/client/types';

const MAX_DELAY_AFTER_EXECUTION = 2000;
const CHECK_ELEMENT_IN_AUTOMATIONS_INTERVAL = 250;
Expand All @@ -20,7 +21,7 @@ interface ActionExecutorOptions {
globalSelectorTimeout: number;
testSpeed: number;
executeSelectorFn: ExecuteSelectorFn<HTMLElement>;
dispatchProxylessEventFn: Function;
dispatchProxylessEventFn: DispatchEventFn;
leftTopPoint: AxisValues<number>;
}

Expand Down
Loading

0 comments on commit 5b776fe

Please sign in to comment.