cherry-pick(#28198): feat(recorder): UX updates for assertion tools (#28202)

- No locator editor.
- No value editor for `toHaveValue`.
- Visual feedback for `toBeVisible`/`toHaveValue`.
- UI tweaks.
This commit is contained in:
Dmitry Gozman 2023-11-16 13:30:01 -08:00 committed by GitHub
parent 59e8f4815d
commit b8949166dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 103 additions and 160 deletions

View file

@ -8,7 +8,7 @@ toc_max_heading_level: 2
### Test Generator Update
![Playwright Test Generator](https://github.com/microsoft/playwright/assets/9881434/8c3d6fac-5381-4aaf-920f-6e22b964eec6)
![Playwright Test Generator](https://github.com/microsoft/playwright/assets/9881434/e8d67e2e-f36d-4301-8631-023948d3e190)
New tools to generate assertions:
- "Assert visibility" tool generates [`method: LocatorAssertions.toBeVisible`].

View file

@ -8,7 +8,7 @@ toc_max_heading_level: 2
### Test Generator Update
![Playwright Test Generator](https://github.com/microsoft/playwright/assets/9881434/8c3d6fac-5381-4aaf-920f-6e22b964eec6)
![Playwright Test Generator](https://github.com/microsoft/playwright/assets/9881434/e8d67e2e-f36d-4301-8631-023948d3e190)
New tools to generate assertions:
- "Assert visibility" tool generates [`method: LocatorAssertions.toBeVisible`].

View file

@ -10,7 +10,7 @@ import LiteYouTube from '@site/src/components/LiteYouTube';
### Test Generator Update
![Playwright Test Generator](https://github.com/microsoft/playwright/assets/9881434/8c3d6fac-5381-4aaf-920f-6e22b964eec6)
![Playwright Test Generator](https://github.com/microsoft/playwright/assets/9881434/e8d67e2e-f36d-4301-8631-023948d3e190)
New tools to generate assertions:
- "Assert visibility" tool generates [`method: LocatorAssertions.toBeVisible`].

View file

@ -8,7 +8,7 @@ toc_max_heading_level: 2
### Test Generator Update
![Playwright Test Generator](https://github.com/microsoft/playwright/assets/9881434/8c3d6fac-5381-4aaf-920f-6e22b964eec6)
![Playwright Test Generator](https://github.com/microsoft/playwright/assets/9881434/e8d67e2e-f36d-4301-8631-023948d3e190)
New tools to generate assertions:
- "Assert visibility" tool generates [`method: LocatorAssertions.toBeVisible`].

View file

@ -44,9 +44,10 @@ x-pw-dialog {
display: flex;
flex-direction: column;
position: absolute;
width: 500px;
height: 200px;
width: 400px;
height: 150px;
z-index: 10;
font-size: 13px;
}
x-pw-dialog-body {
@ -217,6 +218,15 @@ x-pw-tool-item.cancel > x-div {
mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path d='M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/></svg>");
}
x-pw-tool-item.succeeded > x-div {
/* codicon: pass */
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path d='M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z'/></svg>") !important;
mask-image: url("data:image/svg+xml;utf8,<svg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' fill='currentColor'><path d='M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z'/></svg>") !important;
background-color: #388a34 !important;
-webkit-mask-size: 18px !important;
mask-size: 18px !important;
}
x-pw-overlay {
position: absolute;
top: 0;
@ -238,13 +248,15 @@ x-pw-overlay x-pw-tool-item {
}
textarea.text-editor {
font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif;
font-family: system-ui,Ubuntu,Droid Sans,sans-serif;
flex: auto;
border: none;
margin: 6px;
margin: 6px 10px;
color: #333;
outline: 1px solid transparent !important;
outline: 1px solid transparent!important;
resize: none;
padding: 0;
font-size: 13px;
}
textarea.text-editor.does-not-match {

View file

@ -24,28 +24,8 @@ import { isInsideScope } from './domUtils';
import { elementText } from './selectorUtils';
import type { ElementText } from './selectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators';
import { locatorOrSelectorAsSelector } from '@isomorphic/locatorParser';
import { parseSelector } from '@isomorphic/selectorParser';
import { normalizeWhiteSpace } from '@isomorphic/stringUtils';
// @ts-ignore @no-check-deps
import CodeMirrorImpl from 'codemirror-shadow-1';
import type CodeMirrorType from 'codemirror';
// @no-check-deps
import codemirrorCSS from 'codemirror-shadow-1/lib/codemirror.css?inline';
// @no-check-deps
import 'codemirror-shadow-1/mode/css/css';
// @no-check-deps
import 'codemirror-shadow-1/mode/htmlmixed/htmlmixed';
// @no-check-deps
import 'codemirror-shadow-1/mode/javascript/javascript';
// @no-check-deps
import 'codemirror-shadow-1/mode/python/python';
// @no-check-deps
import 'codemirror-shadow-1/mode/clike/clike';
const CodeMirror = CodeMirrorImpl as typeof CodeMirrorType;
interface RecorderDelegate {
performAction?(action: actions.Action): Promise<void>;
recordAction?(action: actions.Action): Promise<void>;
@ -68,6 +48,7 @@ interface RecorderTool {
onMouseDown?(event: MouseEvent): void;
onMouseUp?(event: MouseEvent): void;
onMouseMove?(event: MouseEvent): void;
onMouseEnter?(event: MouseEvent): void;
onMouseLeave?(event: MouseEvent): void;
onFocus?(event: Event): void;
onScroll?(event: Event): void;
@ -109,6 +90,7 @@ class InspectTool implements RecorderTool {
signals: [],
});
this._recorder.delegate.setMode?.('recording');
this._recorder.overlay?.flashToolSucceeded('assertingVisibility');
}
} else {
this._recorder.delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : '');
@ -146,6 +128,10 @@ class InspectTool implements RecorderTool {
this._recorder.updateHighlight(model, true, { color: this._assertVisibility ? '#8acae480' : undefined });
}
onMouseEnter(event: MouseEvent) {
consumeEvent(event);
}
onMouseLeave(event: MouseEvent) {
consumeEvent(event);
const window = this._recorder.injectedScript.window;
@ -518,14 +504,23 @@ class TextAssertionTool implements RecorderTool {
}
onClick(event: MouseEvent) {
consumeEvent(event);
if (this._kind === 'value') {
const action = this._generateAction();
if (action) {
this._recorder.delegate.recordAction?.(action);
this._recorder.delegate.setMode?.('recording');
this._recorder.overlay?.flashToolSucceeded('assertingValue');
}
} else {
if (!this._dialogElement)
this._showDialog();
consumeEvent(event);
}
}
onMouseDown(event: MouseEvent) {
const target = this._recorder.deepEventTarget(event);
if (target.nodeName === 'SELECT')
if (this._elementHasValue(target))
event.preventDefault();
}
@ -618,7 +613,7 @@ class TextAssertionTool implements RecorderTool {
if (!this._hoverHighlight?.elements[0])
return;
this._action = this._generateAction();
if (!this._action)
if (!this._action || this._action.name !== 'assertText')
return;
this._dialogElement = this._recorder.document.createElement('x-pw-dialog');
@ -636,50 +631,17 @@ class TextAssertionTool implements RecorderTool {
this._recorder.document.addEventListener('keydown', this._keyboardListener, true);
const toolbarElement = this._recorder.document.createElement('x-pw-tools-list');
toolbarElement.appendChild(this._createLabel(this._action));
const labelElement = this._recorder.document.createElement('label');
labelElement.textContent = 'Assert that element contains text';
toolbarElement.appendChild(labelElement);
toolbarElement.appendChild(this._recorder.document.createElement('x-spacer'));
toolbarElement.appendChild(this._acceptButton);
toolbarElement.appendChild(this._cancelButton);
this._dialogElement.appendChild(toolbarElement);
const bodyElement = this._recorder.document.createElement('x-pw-dialog-body');
const cmStyle = this._recorder.document.createElement('style');
const cmElement = this._recorder.document.createElement('x-locator-editor');
cmStyle.textContent = codemirrorCSS;
bodyElement.appendChild(cmStyle);
bodyElement.appendChild(cmElement);
const cm = CodeMirror(cmElement, {
value: asLocator(this._recorder.state.language, this._action.selector),
mode: cmModeForLanguage(this._recorder.state.language),
readOnly: false,
lineNumbers: false,
lineWrapping: true,
});
cm.on('keydown', (_, event) => {
if (event.key === 'Tab')
(event as any).codemirrorIgnore = true;
});
cm.on('change', () => {
if (this._action) {
const selector = locatorOrSelectorAsSelector(this._recorder.state.language, cm.getValue(), this._recorder.state.testIdAttributeName);
let elements: Element[] = [];
try {
elements = this._recorder.injectedScript.querySelectorAll(parseSelector(selector), this._recorder.document);
} catch {
}
cmElement.classList.toggle('does-not-match', !elements.length);
this._hoverHighlight = elements.length ? {
selector,
elements,
} : null;
this._action.selector = selector;
this._recorder.updateHighlight(this._hoverHighlight, true);
}
});
let elementToFocus: HTMLElement | null = null;
const action = this._action;
if (action.name === 'assertText') {
const textElement = this._recorder.document.createElement('textarea');
textElement.setAttribute('spellcheck', 'false');
textElement.value = this._renderValue(this._action);
@ -692,66 +654,18 @@ class TextAssertionTool implements RecorderTool {
return;
action.text = newValue;
const targetText = normalizeWhiteSpace(elementText(this._textCache, target).full);
const matches = action.substring ? newValue && targetText.includes(newValue) : targetText === newValue;
const matches = newValue && targetText.includes(newValue);
textElement.classList.toggle('does-not-match', !matches);
};
textElement.addEventListener('input', updateAndValidate);
bodyElement.appendChild(textElement);
// Add a toolbar substring checkbox.
const substringElement = this._recorder.document.createElement('label');
substringElement.style.cursor = 'pointer';
const checkboxElement = this._recorder.document.createElement('input');
substringElement.appendChild(checkboxElement);
substringElement.appendChild(this._recorder.document.createTextNode('Substring'));
checkboxElement.type = 'checkbox';
checkboxElement.style.cursor = 'pointer';
checkboxElement.checked = action.substring;
checkboxElement.addEventListener('change', () => {
action.substring = checkboxElement.checked;
updateAndValidate();
});
toolbarElement.insertBefore(substringElement, this._acceptButton);
elementToFocus = textElement;
} else if (action.name === 'assertValue') {
const textElement = this._recorder.document.createElement('textarea');
textElement.setAttribute('spellcheck', 'false');
textElement.value = this._renderValue(this._action);
textElement.classList.add('text-editor');
textElement.addEventListener('input', () => {
action.value = textElement.value;
});
bodyElement.appendChild(textElement);
elementToFocus = textElement;
} else if (action.name === 'assertChecked') {
const labelElement = this._recorder.document.createElement('label');
labelElement.textContent = 'Value:';
const checkboxElement = this._recorder.document.createElement('input');
labelElement.appendChild(checkboxElement);
checkboxElement.type = 'checkbox';
checkboxElement.checked = action.checked;
checkboxElement.addEventListener('change', () => {
action.checked = checkboxElement.checked;
});
bodyElement.appendChild(labelElement);
elementToFocus = labelElement;
}
this._dialogElement.appendChild(bodyElement);
this._recorder.highlight.appendChild(this._dialogElement);
const position = this._recorder.highlight.tooltipPosition(this._recorder.highlight.firstBox()!, this._dialogElement);
this._dialogElement.style.top = position.anchorTop + 'px';
this._dialogElement.style.left = position.anchorLeft + 'px';
elementToFocus?.focus();
cm.refresh();
}
private _createLabel(action: actions.AssertAction) {
const labelElement = this._recorder.document.createElement('label');
labelElement.textContent = action.name === 'assertText' ? 'Assert text' : action.name === 'assertValue' ? 'Assert value' : 'Assert checked';
return labelElement;
textElement.focus();
}
private _closeDialog() {
@ -829,7 +743,7 @@ class Overlay {
toolsListElement.appendChild(this._assertVisibilityToggle);
this._assertTextToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
this._assertTextToggle.title = 'Assert text and values';
this._assertTextToggle.title = 'Assert text';
this._assertTextToggle.classList.add('text');
this._assertTextToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div'));
this._assertTextToggle.addEventListener('click', () => {
@ -853,7 +767,7 @@ class Overlay {
install() {
this._recorder.highlight.appendChild(this._overlayElement);
this._measure = this._overlayElement.getBoundingClientRect();
this._updateVisualPosition();
}
contains(element: Element) {
@ -874,13 +788,31 @@ class Overlay {
this._updateVisualPosition();
}
if (state.mode === 'none')
this._overlayElement.setAttribute('hidden', 'true');
this._hideOverlay();
else
this._showOverlay();
}
flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') {
const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle;
element.classList.add('succeeded');
setTimeout(() => element.classList.remove('succeeded'), 2000);
}
private _hideOverlay() {
this._overlayElement.setAttribute('hidden', 'true');
}
private _showOverlay() {
if (!this._overlayElement.hasAttribute('hidden'))
return;
this._overlayElement.removeAttribute('hidden');
this._updateVisualPosition();
}
private _updateVisualPosition() {
this._overlayElement.style.left = (this._recorder.injectedScript.window.innerWidth / 2 + this._offsetX) + 'px';
this._measure = this._overlayElement.getBoundingClientRect();
this._overlayElement.style.left = ((this._recorder.injectedScript.window.innerWidth - this._measure.width) / 2 + this._offsetX) + 'px';
}
onMouseMove(event: MouseEvent) {
@ -890,8 +822,8 @@ class Overlay {
}
if (this._dragState) {
this._offsetX = this._dragState.offsetX + event.clientX - this._dragState.dragStart.x;
this._offsetX = Math.min(this._recorder.injectedScript.window.innerWidth / 2 - 10 - this._measure.width, this._offsetX);
this._offsetX = Math.max(10 - this._recorder.injectedScript.window.innerWidth / 2, this._offsetX);
const halfGapSize = (this._recorder.injectedScript.window.innerWidth - this._measure.width) / 2 - 10;
this._offsetX = Math.max(-halfGapSize, Math.min(halfGapSize, this._offsetX));
this._updateVisualPosition();
this._recorder.delegate.setOverlayState?.({ offsetX: this._offsetX });
consumeEvent(event);
@ -925,7 +857,7 @@ export class Recorder {
private _tools: Record<Mode, RecorderTool>;
private _actionSelectorModel: HighlightModel | null = null;
readonly highlight: Highlight;
private _overlay: Overlay | undefined;
readonly overlay: Overlay | undefined;
private _styleElement: HTMLStyleElement;
state: UIState = { mode: 'none', testIdAttributeName: 'data-testid', language: 'javascript', overlay: { offsetX: 0 } };
readonly document: Document;
@ -947,8 +879,8 @@ export class Recorder {
};
this._currentTool = this._tools.none;
if (injectedScript.window.top === injectedScript.window) {
this._overlay = new Overlay(this);
this._overlay.setUIState(this.state);
this.overlay = new Overlay(this);
this.overlay.setUIState(this.state);
}
this._styleElement = this.document.createElement('style');
this._styleElement.textContent = `
@ -976,11 +908,12 @@ export class Recorder {
addEventListener(this.document, 'mouseup', event => this._onMouseUp(event as MouseEvent), true),
addEventListener(this.document, 'mousemove', event => this._onMouseMove(event as MouseEvent), true),
addEventListener(this.document, 'mouseleave', event => this._onMouseLeave(event as MouseEvent), true),
addEventListener(this.document, 'mouseenter', event => this._onMouseEnter(event as MouseEvent), true),
addEventListener(this.document, 'focus', event => this._onFocus(event), true),
addEventListener(this.document, 'scroll', event => this._onScroll(event), true),
];
this.highlight.install();
this._overlay?.install();
this.overlay?.install();
this.injectedScript.document.head.appendChild(this._styleElement);
}
@ -1011,7 +944,7 @@ export class Recorder {
this.state = state;
this.highlight.setLanguage(state.language);
this._switchCurrentTool();
this._overlay?.setUIState(state);
this.overlay?.setUIState(state);
// Race or scroll.
if (this._actionSelectorModel?.selector && !this._actionSelectorModel?.elements.length)
@ -1030,7 +963,7 @@ export class Recorder {
private _onClick(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._overlay?.onClick(event))
if (this.overlay?.onClick(event))
return;
if (this._ignoreOverlayEvent(event))
return;
@ -1072,7 +1005,7 @@ export class Recorder {
private _onMouseUp(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._overlay?.onMouseUp(event))
if (this.overlay?.onMouseUp(event))
return;
if (this._ignoreOverlayEvent(event))
return;
@ -1082,13 +1015,21 @@ export class Recorder {
private _onMouseMove(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._overlay?.onMouseMove(event))
if (this.overlay?.onMouseMove(event))
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onMouseMove?.(event);
}
private _onMouseEnter(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._ignoreOverlayEvent(event))
return;
this._currentTool.onMouseEnter?.(event);
}
private _onMouseLeave(event: MouseEvent) {
if (!event.isTrusted)
return;
@ -1149,7 +1090,7 @@ export class Recorder {
deepEventTarget(event: Event): HTMLElement {
for (const element of event.composedPath()) {
if (!this._overlay?.contains(element as Element))
if (!this.overlay?.contains(element as Element))
return element as HTMLElement;
}
return event.composedPath()[0] as HTMLElement;
@ -1301,14 +1242,4 @@ export class PollingRecorder implements RecorderDelegate {
}
}
function cmModeForLanguage(language: Language): string {
if (language === 'python')
return 'python';
if (language === 'java')
return 'text/x-java';
if (language === 'csharp')
return 'text/x-csharp';
return 'javascript';
}
export default PollingRecorder;