Skip to content

Commit

Permalink
feat: selector inspector panel (closes #7375) (#7372)
Browse files Browse the repository at this point in the history
Closes #7375.

### Use cases

1. **Picking + generation**. Click on the `Pick` button and select the
element with a mouse click. As a result, several selectors matching this
element will be generated. The first of the generated selectors will be
placed in the selector input, the rest are available in the list of
selectors, which can be expanded by clicking on the arrow to the left of
the `Copy` button.
2. **Debug selector**. Start typing the selector in CSS or TestCafe
format. As you type, all matching elements will be highlighted with a
frame. In addition, to the right of the input field there is an
indicator that shows the number of matching elements.

### Peculiarities

**In the picking mode** all elements of TestCafe UI, except for the
frame that highlights the element, disappear.

**Input selector format**. The Selector Inspector recognizes selectors
in two formats: CSS and TestCafe.
CSS: `div > div input`, `p:nth-child(2)`,
`span[custom-attribute="value"].className`
TestCafe: `Selector('.my-class')`, `Selector('div').withText('Hello
World!')`

**Match indicator**. It has three states: `not found`, `incorrect
selector` and `found: {number of elements}`.

**List of generated selectors**. Cleared as soon as the user starts
typing in the selector input field.

**Highlighting elements** matching the input selector is active only
when the selector input has focus.

### Restrictions

1. The inspector is currently only available in debug mode (`await
t.debug()`).
2. Picking does not work in iframes.
3. Picking doesn't work with Shadow DOM.
4. The selector generator is not configurable.
5. There is only one generator so far, and the user will not be able to
redefine the generation function.

## Initial state

![изображение](https://user-images.githubusercontent.com/34184692/213652793-ad8cc93f-8f68-466d-870f-28a89036d519.png)

## Picking

![021](https://user-images.githubusercontent.com/34184692/213653402-c08f2723-7343-402f-9b2f-2b2a5c0e3134.gif)

## Selector typing

![022](https://user-images.githubusercontent.com/34184692/213653738-ff59cb80-ab19-4a2d-82f9-168955f6a525.gif)

## Selecting a selector from a list

![023](https://user-images.githubusercontent.com/34184692/213656937-ba08fddc-5bba-4fdd-9f55-7d40f37a179a.gif)

## Copy selector

![024](https://user-images.githubusercontent.com/34184692/213654567-77c5ef12-bfa2-4c74-a057-5ecc9b2a6f99.gif)
  • Loading branch information
felis2803 authored Feb 10, 2023
1 parent 51cde17 commit 90fe0ac
Show file tree
Hide file tree
Showing 33 changed files with 1,763 additions and 187 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,12 @@
"@babel/preset-flow": "^7.12.1",
"@babel/preset-react": "^7.12.1",
"@babel/runtime": "^7.12.5",
"@devexpress/bin-v8-flags-filter": "^1.3.0",
"@miherlosev/esm": "3.2.26",
"@types/node": "^12.20.10",
"async-exit-hook": "^1.1.2",
"babel-plugin-module-resolver": "^5.0.0",
"babel-plugin-syntax-trailing-function-commas": "^6.22.0",
"@devexpress/bin-v8-flags-filter": "^1.3.0",
"bowser": "^2.8.1",
"callsite": "^1.0.0",
"callsite-record": "^4.0.0",
Expand Down Expand Up @@ -151,6 +151,7 @@
"testcafe-reporter-spec": "^2.1.1",
"testcafe-reporter-xunit": "^2.2.1",
"testcafe-safe-storage": "^1.1.1",
"testcafe-selector-generator": "^0.1.0",
"time-limit-promise": "^1.0.2",
"tmp": "0.0.28",
"tree-kill": "^1.2.2",
Expand Down
34 changes: 34 additions & 0 deletions src/browser/connection/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { IncomingMessage, ServerResponse } from 'http';
import SERVICE_ROUTES from './service-routes';
import EMPTY_PAGE_MARKUP from '../../proxyless/empty-page-markup';
import PROXYLESS_ERROR_ROUTE from '../../proxyless/error-route';
import { initSelector } from '../../test-run/commands/validations/initializers';
import TestRun from '../../test-run';

export default class BrowserConnectionGateway {
private _connections: Dictionary<BrowserConnection> = {};
Expand Down Expand Up @@ -72,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.parseSelector}/{id}`, proxy, BrowserConnectionGateway._parseSelector, 'POST');

proxy.GET(SERVICE_ROUTES.connect, (req: IncomingMessage, res: ServerResponse) => this._connectNextRemoteBrowser(req, res));
proxy.GET(SERVICE_ROUTES.connectWithTrailingSlash, (req: IncomingMessage, res: ServerResponse) => this._connectNextRemoteBrowser(req, res));
Expand Down Expand Up @@ -245,6 +248,37 @@ export default class BrowserConnectionGateway {
respond500(res, 'There are no available _connections to establish.');
}

private static _getParsedSelector (testRun: TestRun, rawSelector: string): any {
const options = {
testRun,

skipVisibilityCheck: true,
collectionMode: true,
};

const value = rawSelector.trim().startsWith('Selector(') ? rawSelector : `'${rawSelector}'`;
const selector = { type: 'js-expr', value };

return initSelector('selector', selector, options);
}

private static _parseSelector (req: IncomingMessage, res: ServerResponse, connection: BrowserConnection): void {
if (BrowserConnectionGateway._ensureConnectionReady(res, connection)) {
BrowserConnectionGateway._fetchRequestData(req, data => {
try {
const testRun = connection.getCurrentTestRun();
const rawSelector = JSON.parse(data).selector;
const parsedSelector = BrowserConnectionGateway._getParsedSelector(testRun, rawSelector);

respondWithJSON(res, parsedSelector);
}
catch (error) {
respondWithJSON(res);
}
});
}
}

// API
public startServingConnection (connection: BrowserConnection): void {
this._connections[connection.id] = connection;
Expand Down
2 changes: 2 additions & 0 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 parseSelectorRelativeUrl = '';
private readonly debugLogger: debug.Debugger;
private osInfo: OSInfo | null = null;

Expand Down Expand Up @@ -195,6 +196,7 @@ export default class BrowserConnection extends EventEmitter {
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.idleUrl = proxy.resolveRelativeServiceUrl(this.idleRelativeUrl);
this.heartbeatUrl = proxy.resolveRelativeServiceUrl(this.heartbeatRelativeUrl);
Expand Down
1 change: 1 addition & 0 deletions src/browser/connection/service-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default {
serviceWorker: '/service-worker.js',
openFileProtocol: '/browser/open-file-protocol',
dispatchProxylessEvent: '/browser/dispatch-proxyless-event',
parseSelector: '/parse-selector',

assets: {
index: '/browser/assets/index.js',
Expand Down
7 changes: 7 additions & 0 deletions src/client/browser/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,10 @@ export async function dispatchProxylessEvent (dispatchProxylessEventUrl, testCaf

await testCafeUI.show();
}

export function parseSelector (parseSelectorUrl, createXHR, selector) {
return sendXHR(parseSelectorUrl, createXHR, {
method: 'POST',
data: JSON.stringify({ selector }), //eslint-disable-line no-restricted-globals
});
}
15 changes: 11 additions & 4 deletions src/client/driver/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ export default class Driver extends serviceUtils.EventEmitter {
this.childWindowDriverLinks = [];
this.parentWindowDriverLink = null;

this.statusBar = null;
this.statusBar = null;
this.selectorInspectorPanel = null;

this.windowId = this._getCurrentWindowId();
this.role = DriverRole.replica;
Expand Down Expand Up @@ -247,7 +248,7 @@ export default class Driver extends serviceUtils.EventEmitter {
}

set consoleMessages (messages) {
return this.contextStorage.setItem(CONSOLE_MESSAGES, messages ? messages.getCopy() : null);
this.contextStorage.setItem(CONSOLE_MESSAGES, messages ? messages.getCopy() : null);
}

async _getReadyPromise () {
Expand Down Expand Up @@ -1454,6 +1455,8 @@ export default class Driver extends serviceUtils.EventEmitter {
_onSetBreakpointCommand ({ isTestError, inCompilerService }) {
const showDebuggingStatusPromise = this.statusBar.showDebuggingStatus(isTestError);

this.selectorInspectorPanel.show();

if (inCompilerService) {
showDebuggingStatusPromise.then(debug => {
this.debug = debug;
Expand Down Expand Up @@ -1658,6 +1661,9 @@ export default class Driver extends serviceUtils.EventEmitter {
_executeCommand (command) {
this.contextStorage.setItem(this.WINDOW_COMMAND_API_CALL_FLAG, false);

if (this.selectorInspectorPanel)
this.selectorInspectorPanel.hide();

if (this.customCommandHandlers[command.type])
this._onCustomCommand(command);

Expand Down Expand Up @@ -1903,8 +1909,9 @@ export default class Driver extends serviceUtils.EventEmitter {
proxyless,
});

this.nativeDialogsTracker = new NativeDialogTracker(this.contextStorage, { proxyless, dialogHandler });
this.statusBar = new testCafeUI.StatusBar(this.runInfo.userAgent, this.runInfo.fixtureName, this.runInfo.testName, this.contextStorage);
this.nativeDialogsTracker = new NativeDialogTracker(this.contextStorage, { proxyless, dialogHandler });
this.statusBar = new testCafeUI.StatusBar(this.runInfo.userAgent, this.runInfo.fixtureName, this.runInfo.testName, this.contextStorage);
this.selectorInspectorPanel = new testCafeUI.SelectorInspectorPanel(this.statusBar);

const self = this;

Expand Down
2 changes: 2 additions & 0 deletions src/client/test-run/index.js.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
var browserActiveWindowIdUrl = origin + {{{browserActiveWindowIdUrl}}};
var browserCloseWindowUrl = origin + {{{browserCloseWindowUrl}}};
var browserDispatchProxylessEventUrl = origin + {{{browserDispatchProxylessEventRelativeUrl}}};
var browserParseSelectorUrl = origin + {{{browserParseSelectorUrl}}};
var skipJsErrors = {{{skipJsErrors}}};
var dialogHandler = {{{dialogHandler}}};
var userAgent = {{{userAgent}}};
Expand All @@ -47,6 +48,7 @@
closeWindow: browserCloseWindowUrl,
openFileProtocolUrl: browserOpenFileProtocolUrl,
dispatchProxylessEvent: browserDispatchProxylessEventUrl,
parseSelector: browserParseSelectorUrl,
},
{
userAgent: userAgent,
Expand Down
3 changes: 3 additions & 0 deletions src/client/ui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import cursorUI from './cursor';
import iframeCursorUI from './cursor/iframe-cursor';
import screenshotMark from './screenshot-mark';
import uiRoot from './ui-root';
import SelectorInspectorPanel from './selector-inspector-panel';


const Promise = hammerhead.Promise;
Expand Down Expand Up @@ -45,6 +46,8 @@ exports.ProgressPanel = ProgressPanel;
exports.StatusBar = StatusBar;
exports.IframeStatusBar = IframeStatusBar;

exports.SelectorInspectorPanel = SelectorInspectorPanel;


exports.hide = function (hideTopRoot) {
if (hideTopRoot)
Expand Down
54 changes: 54 additions & 0 deletions src/client/ui/selector-inspector-panel/copy-button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import hammerhead from './../deps/hammerhead';
import testCafeCore from './../deps/testcafe-core';

import { createElementFromDescriptor } from './utils/create-element-from-descriptor';
import { setStyles } from './utils/set-styles';
import { copy } from './utils/copy';

import * as descriptors from './descriptors';

const nativeMethods = hammerhead.nativeMethods;

const eventUtils = testCafeCore.eventUtils;

const ANIMATION_TIMEOUT = 1200;

const VALUES = {
copy: 'Copy',
copied: 'Copied!',
};

export class CopyButton {
element;
sourceElement;

constructor (sourceElement) {
this.element = createElementFromDescriptor(descriptors.copyButton);
this.sourceElement = sourceElement;

eventUtils.bind(this.element, 'click', () => this._copySelector());
}

_copySelector () {
// eslint-disable-next-line no-restricted-properties
copy(this.sourceElement.value);

this._animate();
}

_animate () {
this._changeAppearance(VALUES.copied, 'bold');

nativeMethods.setTimeout.call(window, () => this._resetAppearance(), ANIMATION_TIMEOUT);
}

_resetAppearance () {
this._changeAppearance(VALUES.copy, '');
}

_changeAppearance (value, fontWeight) {
nativeMethods.inputValueSetter.call(this.element, value);

setStyles(this.element, { fontWeight });
}
}
57 changes: 57 additions & 0 deletions src/client/ui/selector-inspector-panel/descriptors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export const pickButton = {
tag: 'input',
type: 'button',
value: 'Pick',
class: 'pick-button',
};

export const selectorInput = {
tag: 'input',
type: 'text',
class: 'selector-input',
};

export const matchIndicator = {
class: 'match-indicator',
text: 'No Matching Elements',
};

export const expandSelectorsList = {
class: 'expand-selector-list',
};

export const selectorInputContainer = {
class: 'selector-input-container',
};

export const copyButton = {
tag: 'input',
type: 'button',
value: 'Copy',
};

export const selectorsList = {
class: 'selectors-list',
};

export const panel = {
class: 'selector-inspector-panel',
};

export const elementFrame = {
class: 'element-frame',
};

export const tooltip = {
class: 'tooltip',
};

export const arrow = {
class: 'arrow',
};

export const auxiliaryCopyInput = {
tag: 'input',
type: 'text',
class: 'auxiliary-input',
};
Loading

0 comments on commit 90fe0ac

Please sign in to comment.