chore: rework assert dialog (#28043)

This commit is contained in:
Pavel Feldman 2023-11-08 20:09:58 -08:00 committed by GitHub
parent 5f527fedb1
commit b004c1a0a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 244 additions and 202 deletions

View File

@ -14,16 +14,18 @@
* limitations under the License.
*/
export const highlightCSS = `
:host {
font-size: 13px;
font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif;
color: #333;
}
x-pw-tooltip {
backdrop-filter: blur(5px);
background-color: white;
color: #222;
border-radius: 6px;
box-shadow: 0 0.5rem 1.2rem rgba(0,0,0,.3);
display: none;
font-family: 'Dank Mono', 'Operator Mono', Inconsolata, 'Fira Mono',
'SF Mono', Monaco, 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 12.8px;
font-weight: normal;
left: 0;
@ -31,11 +33,27 @@ x-pw-tooltip {
max-width: 600px;
position: absolute;
top: 0;
padding: 4px;
}
x-pw-tooltip-body {
align-items: center;
padding: 3.2px 5.12px 3.2px;
x-pw-dialog {
background-color: white;
pointer-events: auto;
border-radius: 6px;
box-shadow: 0 0.5rem 1.2rem rgba(0,0,0,.3);
display: flex;
flex-direction: column;
position: absolute;
min-width: 500px;
min-height: 200px;
}
x-pw-dialog-body {
display: flex;
flex-direction: column;
flex: auto;
}
x-pw-highlight {
position: absolute;
top: 0;
@ -43,6 +61,7 @@ x-pw-highlight {
width: 0;
height: 0;
}
x-pw-action-point {
position: absolute;
width: 20px;
@ -52,20 +71,24 @@ x-pw-action-point {
margin: -10px 0 0 -10px;
z-index: 2;
}
x-pw-separator {
height: 1px;
margin: 6px 9px;
background: rgb(148 148 148 / 90%);
}
x-pw-tool-gripper {
height: 28px;
width: 24px;
margin: 2px 0;
cursor: grab;
}
x-pw-tool-gripper:active {
cursor: grabbing;
}
x-pw-tool-gripper > x-div {
width: 100%;
height: 100%;
@ -79,11 +102,20 @@ x-pw-tool-gripper > 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='M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z' /></svg>");
background-color: #555555;
}
x-pw-tool-label {
display: flex;
align-items: center;
margin-left: 10px;
user-select: none;
}
x-pw-tools-list {
display: flex;
width: 100%;
border-bottom: 1px solid #dddddd;
}
x-pw-tool-item {
pointer-events: auto;
cursor: pointer;
@ -91,9 +123,11 @@ x-pw-tool-item {
width: 28px;
border-radius: 3px;
}
x-pw-tool-item:not(.disabled):hover {
background-color: hsl(0, 0%, 86%);
}
x-pw-tool-item > x-div {
width: 100%;
height: 100%;
@ -105,45 +139,52 @@ x-pw-tool-item > x-div {
mask-size: 16px;
background-color: #3a3a3a;
}
x-pw-tool-item.disabled > x-div {
background-color: rgba(97, 97, 97, 0.5);
cursor: default;
}
x-pw-tool-item.active > x-div {
background-color: #006ab1;
}
x-pw-tool-item.record.active > x-div {
background-color: #a1260d;
}
x-pw-tool-item.accept > x-div {
background-color: #388a34;
}
x-pw-tool-item.cancel > x-div {
background-color: #e51400;
}
x-pw-tool-item.record > x-div {
/* codicon: circle-large-filled */
-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='M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z'/></svg>");
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='M8 1a6.8 6.8 0 0 1 1.86.253 6.899 6.899 0 0 1 3.083 1.805 6.903 6.903 0 0 1 1.804 3.083C14.916 6.738 15 7.357 15 8s-.084 1.262-.253 1.86a6.9 6.9 0 0 1-.704 1.674 7.157 7.157 0 0 1-2.516 2.509 6.966 6.966 0 0 1-1.668.71A6.984 6.984 0 0 1 8 15a6.984 6.984 0 0 1-1.86-.246 7.098 7.098 0 0 1-1.674-.711 7.3 7.3 0 0 1-1.415-1.094 7.295 7.295 0 0 1-1.094-1.415 7.098 7.098 0 0 1-.71-1.675A6.985 6.985 0 0 1 1 8c0-.643.082-1.262.246-1.86a6.968 6.968 0 0 1 .711-1.667 7.156 7.156 0 0 1 2.509-2.516 6.895 6.895 0 0 1 1.675-.704A6.808 6.808 0 0 1 8 1z'/></svg>");
}
x-pw-tool-item.pick-locator > x-div {
/* codicon: inspect */
-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 fill-rule='evenodd' clip-rule='evenodd' d='M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z'/></svg>");
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 fill-rule='evenodd' clip-rule='evenodd' d='M1 3l1-1h12l1 1v6h-1V3H2v8h5v1H2l-1-1V3zm14.707 9.707L9 6v9.414l2.707-2.707h4zM10 13V8.414l3.293 3.293h-2L10 13z'/></svg>");
}
x-pw-tool-item.assert > x-div {
/* codicon: check-all */
-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 fill-rule='evenodd' clip-rule='evenodd' d='M15.62 3.596L7.815 12.81l-.728-.033L4 8.382l.754-.53 2.744 3.907L14.917 3l.703.596z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M7.234 8.774l4.386-5.178L10.917 3l-4.23 4.994.547.78zm-1.55.403l.548.78-.547-.78zm-1.617 1.91l.547.78-.799.943-.728-.033L0 8.382l.754-.53 2.744 3.907.57-.672z'/></svg>");
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 fill-rule='evenodd' clip-rule='evenodd' d='M15.62 3.596L7.815 12.81l-.728-.033L4 8.382l.754-.53 2.744 3.907L14.917 3l.703.596z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M7.234 8.774l4.386-5.178L10.917 3l-4.23 4.994.547.78zm-1.55.403l.548.78-.547-.78zm-1.617 1.91l.547.78-.799.943-.728-.033L0 8.382l.754-.53 2.744 3.907.57-.672z'/></svg>");
}
x-pw-tool-item.accept > x-div {
-webkit-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='M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/></svg>");
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='M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/></svg>");
}
x-pw-tool-item.cancel > x-div {
-webkit-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>");
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-overlay {
position: absolute;
top: 0;
@ -152,21 +193,52 @@ x-pw-overlay {
background: transparent;
pointer-events: auto;
}
x-pw-overlay x-pw-tools-list {
background-color: #ffffffdd;
box-shadow: rgba(0, 0, 0, 0.1) 0px 5px 5px;
border-radius: 3px;
border-bottom: none;
}
x-pw-overlay x-pw-tool-item {
margin: 2px;
}
input.locator-editor {
display: flex;
padding: 10px;
flex: none;
border: none;
border-bottom: 1px solid #dddddd;
}
input.locator-editor:focus,
textarea.text-editor:focus {
outline: none;
}
textarea.text-editor {
font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif;
flex: auto;
border: none;
padding: 10px;
color: #333;
}
x-div {
display: block;
}
x-spacer {
flex: auto;
}
* {
box-sizing: border-box;
}
*[hidden] {
display: none !important;
}`;
}

View File

@ -19,7 +19,7 @@ import type { ParsedSelector } from '../../utils/isomorphic/selectorParser';
import type { InjectedScript } from './injectedScript';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators';
import { highlightCSS } from './highlight.css';
import highlightCSS from './highlight.css?inline';
type HighlightEntry = {
targetElement: Element,
@ -34,9 +34,6 @@ type HighlightEntry = {
export type HighlightOptions = {
tooltipText?: string;
color?: string;
anchorGetter?: (element: Element) => DOMRect;
toolbar?: Element[];
interactive?: boolean;
};
export class Highlight {
@ -63,7 +60,12 @@ export class Highlight {
this._glassPaneElement.style.pointerEvents = 'none';
this._glassPaneElement.style.display = 'flex';
this._glassPaneElement.style.backgroundColor = 'transparent';
for (const eventName of ['click', 'auxclick', 'dragstart', 'input', 'keydown', 'keyup', 'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'mousemove', 'mouseleave', 'focus', 'scroll']) {
this._glassPaneElement.addEventListener(eventName, e => {
e.stopPropagation();
e.stopImmediatePropagation();
});
}
this._actionPointElement = document.createElement('x-pw-action-point');
this._actionPointElement.setAttribute('hidden', 'true');
this._glassPaneShadow = this._glassPaneElement.attachShadow({ mode: this._isUnderTest ? 'open' : 'closed' });
@ -145,26 +147,12 @@ export class Highlight {
let tooltipElement;
if (options.tooltipText) {
tooltipElement = this._injectedScript.document.createElement('x-pw-tooltip');
this._glassPaneShadow.appendChild(tooltipElement);
const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : '';
tooltipElement.textContent = options.tooltipText + suffix;
tooltipElement.style.top = '0';
tooltipElement.style.left = '0';
tooltipElement.style.display = 'flex';
tooltipElement.style.flexDirection = 'column';
tooltipElement.style.alignItems = 'start';
if (options.interactive)
tooltipElement.style.pointerEvents = 'auto';
if (options.toolbar) {
const toolbar = this._injectedScript.document.createElement('x-pw-tools-list');
tooltipElement.appendChild(toolbar);
for (const toolbarElement of options.toolbar)
toolbar.appendChild(toolbarElement);
}
const bodyElement = this._injectedScript.document.createElement('x-pw-tooltip-body');
tooltipElement.appendChild(bodyElement);
this._glassPaneShadow.appendChild(tooltipElement);
const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : '';
bodyElement.textContent = options.tooltipText + suffix;
}
this._highlightEntries.push({ targetElement: elements[i], tooltipElement, highlightElement, tooltipText: options.tooltipText });
}
@ -176,25 +164,7 @@ export class Highlight {
continue;
// Position tooltip, if any.
const tooltipWidth = entry.tooltipElement.offsetWidth;
const tooltipHeight = entry.tooltipElement.offsetHeight;
const totalWidth = this._glassPaneElement.offsetWidth;
const totalHeight = this._glassPaneElement.offsetHeight;
const anchorBox = options.anchorGetter ? options.anchorGetter(entry.targetElement) : entry.box;
let anchorLeft = anchorBox.left;
if (anchorLeft + tooltipWidth > totalWidth - 5)
anchorLeft = totalWidth - tooltipWidth - 5;
let anchorTop = anchorBox.bottom + 5;
if (anchorTop + tooltipHeight > totalHeight - 5) {
// If can't fit below, either position above...
if (anchorBox.top > tooltipHeight + 5) {
anchorTop = anchorBox.top - tooltipHeight - 5;
} else {
// Or on top in case of large element
anchorTop = totalHeight - 5 - tooltipHeight;
}
}
const { anchorLeft, anchorTop } = this.tooltipPosition(entry.box, entry.tooltipElement);
entry.tooltipTop = anchorTop;
entry.tooltipLeft = anchorLeft;
}
@ -219,6 +189,33 @@ export class Highlight {
console.error('Highlight box for test: ' + JSON.stringify({ x: box.x, y: box.y, width: box.width, height: box.height })); // eslint-disable-line no-console
}
}
firstBox(): DOMRect | undefined {
return this._highlightEntries[0]?.box;
}
tooltipPosition(box: DOMRect, tooltipElement: HTMLElement) {
const tooltipWidth = tooltipElement.offsetWidth;
const tooltipHeight = tooltipElement.offsetHeight;
const totalWidth = this._glassPaneElement.offsetWidth;
const totalHeight = this._glassPaneElement.offsetHeight;
let anchorLeft = box.left;
if (anchorLeft + tooltipWidth > totalWidth - 5)
anchorLeft = totalWidth - tooltipWidth - 5;
let anchorTop = box.bottom + 5;
if (anchorTop + tooltipHeight > totalHeight - 5) {
// If can't fit below, either position above...
if (box.top > tooltipHeight + 5) {
anchorTop = box.top - tooltipHeight - 5;
} else {
// Or on top in case of large element
anchorTop = totalHeight - 5 - tooltipHeight;
}
}
return { anchorLeft, anchorTop };
}
private _highlightIsUpToDate(elements: Element[], tooltipText: string | undefined): boolean {
if (elements.length !== this._highlightEntries.length)
return false;

View File

@ -20,10 +20,12 @@ import { generateSelector } from '../injected/selectorGenerator';
import type { Point } from '../../common/types';
import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes';
import { Highlight, type HighlightOptions } from '../injected/highlight';
import { enclosingElement, isInsideScope, parentElementOrShadowHost } from './domUtils';
import { isInsideScope } from './domUtils';
import { elementText } from './selectorUtils';
import { escapeWithQuotes, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import { locatorOrSelectorAsSelector } from '@isomorphic/locatorParser';
import { parseSelector } from '@isomorphic/selectorParser';
import { normalizeWhiteSpace } from '@isomorphic/stringUtils';
interface RecorderDelegate {
performAction?(action: actions.Action): Promise<void>;
@ -442,217 +444,187 @@ class RecordActionTool implements RecorderTool {
class TextAssertionTool implements RecorderTool {
private _hoverHighlight: HighlightModel | null = null;
private _selectionHighlight: HighlightModel | null = null;
private _selectionText: { selectedText: string, fullText: string } | null = null;
private _inputHighlight: HighlightModel | null = null;
private _action: actions.AssertAction | null = null;
private _dialogElement: HTMLElement | null = null;
private _acceptButton: HTMLElement;
private _cancelButton: HTMLElement;
private _keyboardListener: ((event: KeyboardEvent) => void) | undefined;
constructor(private _recorder: Recorder) {
this._acceptButton = this._recorder.document.createElement('x-pw-tool-item');
this._acceptButton.title = 'Accept';
this._acceptButton.classList.add('accept');
this._acceptButton.appendChild(this._recorder.document.createElement('x-div'));
this._acceptButton.addEventListener('click', () => this._commitAction());
this._acceptButton.addEventListener('click', () => this._commit());
this._cancelButton = this._recorder.document.createElement('x-pw-tool-item');
this._cancelButton.title = 'Close';
this._cancelButton.classList.add('cancel');
this._cancelButton.appendChild(this._recorder.document.createElement('x-div'));
this._cancelButton.addEventListener('click', () => this._cancelAction());
this._cancelButton.addEventListener('click', () => this._closeDialog());
}
cursor() {
return 'text';
return 'pointer';
}
cleanup() {
this._closeDialog();
this._hoverHighlight = null;
this._selectionHighlight = null;
this._selectionText = null;
this._inputHighlight = null;
}
onClick(event: MouseEvent) {
if (!this._dialogElement)
this._showDialog();
consumeEvent(event);
const selection = this._recorder.document.getSelection();
if (event.detail === 1 && selection && !selection.toString() && !this._inputHighlight) {
const target = this._recorder.deepEventTarget(event);
selection.selectAllChildren(target);
this._updateSelectionHighlight();
}
}
onMouseDown(event: MouseEvent) {
const target = this._recorder.deepEventTarget(event);
if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
this._recorder.injectedScript.window.getSelection()?.empty();
this._inputHighlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
this._showHighlight(true);
consumeEvent(event);
return;
}
this._inputHighlight = null;
this._hoverHighlight = null;
this._updateSelectionHighlight();
}
onMouseUp(event: MouseEvent) {
this._updateSelectionHighlight();
}
onMouseMove(event: MouseEvent) {
const selection = this._recorder.document.getSelection();
if (selection && selection.toString()) {
this._updateSelectionHighlight();
return;
}
if (this._inputHighlight || event.buttons)
if (this._dialogElement)
return;
const target = this._recorder.deepEventTarget(event);
if (this._hoverHighlight?.elements[0] === target)
return;
this._hoverHighlight = elementText(new Map(), target).full ? { elements: [target], selector: '' } : null;
this._hoverHighlight = target.nodeName === 'INPUT' || target.nodeName === 'TEXTAREA' || elementText(new Map(), target).full ? { elements: [target], selector: '' } : null;
this._recorder.updateHighlight(this._hoverHighlight, true, { color: '#8acae480' });
}
onDragStart(event: DragEvent) {
consumeEvent(event);
}
onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
const selection = this._recorder.document.getSelection();
if (selection && selection.toString())
this._resetSelectionAndHighlight();
else
this._recorder.delegate.setMode?.('recording');
consumeEvent(event);
return;
}
if (event.key === 'Enter') {
this._commitAction();
consumeEvent(event);
return;
}
// Only allow keys that control text selection.
if (!['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Shift', 'Control', 'Meta', 'Alt', 'AltGraph'].includes(event.key)) {
consumeEvent(event);
return;
}
}
onKeyUp(event: KeyboardEvent) {
if (event.key === 'Escape')
this._recorder.delegate.setMode?.('recording');
consumeEvent(event);
}
onScroll(event: Event) {
this._hoverHighlight = null;
this._showHighlight(false);
}
private _generateAction(): actions.Action | null {
if (this._inputHighlight) {
const target = this._inputHighlight.elements[0] as HTMLInputElement;
if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes(target.type.toLowerCase())) {
private _generateAction(): actions.AssertAction | null {
const target = this._hoverHighlight?.elements[0];
if (!target)
return null;
if (target.nodeName === 'INPUT' || target.nodeName === 'TEXTAREA') {
const { selector } = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes((target as HTMLInputElement).type.toLowerCase())) {
return {
name: 'assertChecked',
selector: this._inputHighlight.selector,
selector,
signals: [],
// Interestingly, inputElement.checked is reversed inside this event handler.
checked: !(target as HTMLInputElement).checked,
checked: (target as HTMLInputElement).checked,
};
} else {
return {
name: 'assertValue',
selector: this._inputHighlight.selector,
selector,
signals: [],
value: target.value,
value: (target as HTMLInputElement).value,
};
}
} else if (this._selectionText && this._selectionHighlight) {
} else {
const { selector } = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
return {
name: 'assertText',
selector: this._selectionHighlight.selector,
selector,
signals: [],
text: this._selectionText.selectedText,
substring: this._selectionText.fullText !== this._selectionText.selectedText,
text: target.textContent!,
substring: true,
};
}
return null;
}
private _generateActionPreview() {
const action = this._generateAction();
// TODO: support other languages, maybe unify with code generator?
private _renderValue(action: actions.Action) {
if (action?.name === 'assertText')
return `expect(${asLocator(this._recorder.state.language, action.selector)}).${action.substring ? 'toContainText' : 'toHaveText'}(${escapeWithQuotes(action.text)})`;
return normalizeWhiteSpace(action.text);
if (action?.name === 'assertChecked')
return `expect(${asLocator(this._recorder.state.language, action.selector)})${action.checked ? '' : '.not'}.toBeChecked()`;
if (action?.name === 'assertValue') {
const assertion = action.value ? `toHaveValue(${escapeWithQuotes(action.value)})` : `toBeEmpty()`;
return `expect(${asLocator(this._recorder.state.language, action.selector)}).${assertion}`;
}
return String(action.checked);
if (action?.name === 'assertValue')
return action.value;
return '';
}
private _commitAction() {
const action = this._generateAction();
if (action) {
this._resetSelectionAndHighlight();
this._recorder.delegate.recordAction?.(action);
this._recorder.delegate.setMode?.('recording');
}
}
private _cancelAction() {
this._resetSelectionAndHighlight();
}
private _resetSelectionAndHighlight() {
this._selectionHighlight = null;
this._selectionText = null;
this._inputHighlight = null;
this._recorder.injectedScript.window.getSelection()?.empty();
this._recorder.updateHighlight(null, false);
}
private _updateSelectionHighlight() {
if (this._inputHighlight)
private _commit() {
if (!this._action || !this._dialogElement)
return;
const selection = this._recorder.document.getSelection();
const selectedText = normalizeWhiteSpace(selection?.toString() || '');
let highlight: HighlightModel | null = null;
if (selection && selection.focusNode && selection.anchorNode && selectedText) {
const focusElement = enclosingElement(selection.focusNode);
let lcaElement = focusElement ? enclosingElement(selection.anchorNode) : undefined;
while (lcaElement && !isInsideScope(lcaElement, focusElement))
lcaElement = parentElementOrShadowHost(lcaElement);
highlight = lcaElement ? generateSelector(this._recorder.injectedScript, lcaElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true }) : null;
}
const fullText = highlight ? normalizeWhiteSpace(elementText(new Map(), highlight.elements[0]).full) : '';
const selectionText = highlight ? { selectedText, fullText } : null;
if (highlight?.selector === this._selectionHighlight?.selector && this._selectionText?.fullText === selectionText?.fullText && this._selectionText?.selectedText === selectionText?.selectedText)
return;
this._selectionHighlight = highlight;
this._selectionText = selectionText;
this._showHighlight(true);
this._closeDialog();
this._recorder.delegate.recordAction?.(this._action);
this._recorder.delegate.setMode?.('recording');
}
private _showHighlight(userGesture: boolean) {
const options: HighlightOptions = {
color: '#6fdcbd38',
tooltipText: this._generateActionPreview(),
toolbar: [this._acceptButton, this._cancelButton],
interactive: true,
private _showDialog() {
const target = this._hoverHighlight?.elements[0];
if (!target)
return;
this._action = this._generateAction();
if (!this._action)
return;
this._dialogElement = this._recorder.document.createElement('x-pw-dialog');
this._keyboardListener = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
this._closeDialog();
return;
}
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
if (this._dialogElement)
this._commit();
return;
}
};
if (this._inputHighlight) {
this._recorder.updateHighlight(this._inputHighlight, userGesture, options);
} else {
options.anchorGetter = (e: Element) => this._recorder.document.getSelection()?.getRangeAt(0)?.getBoundingClientRect() || e.getBoundingClientRect();
this._recorder.updateHighlight(this._selectionHighlight, userGesture, options);
}
this._recorder.document.addEventListener('keydown', this._keyboardListener, true);
const toolbarElement = this._recorder.document.createElement('x-pw-tools-list');
toolbarElement.appendChild(this._createLabel(this._action));
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 locatorElement = this._recorder.document.createElement('input');
locatorElement.classList.add('locator-editor');
locatorElement.value = asLocator(this._recorder.state.language, this._action.selector);
locatorElement.addEventListener('input', () => {
if (this._action) {
const selector = locatorOrSelectorAsSelector(this._recorder.state.language, locatorElement.value, this._recorder.state.testIdAttributeName);
const model: HighlightModel = {
selector,
elements: this._recorder.injectedScript.querySelectorAll(parseSelector(selector), this._recorder.document),
};
this._action.selector = selector;
this._recorder.updateHighlight(model, true);
}
});
const textElement = this._recorder.document.createElement('textarea');
textElement.value = this._renderValue(this._action);
textElement.classList.add('text-editor');
textElement.addEventListener('input', () => {
if (this._action?.name === 'assertText')
this._action.text = normalizeWhiteSpace(elementText(new Map(), textElement).full);
if (this._action?.name === 'assertChecked')
this._action.checked = textElement.value === 'true';
if (this._action?.name === 'assertValue')
this._action.value = textElement.value;
});
bodyElement.appendChild(locatorElement);
bodyElement.appendChild(textElement);
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';
textElement.focus();
}
private _createLabel(action: actions.AssertAction) {
const labelElement = this._recorder.document.createElement('x-pw-tool-label');
labelElement.textContent = action.name === 'assertText' ? 'Assert text' : action.name === 'assertValue' ? 'Assert value' : 'Assert checked';
return labelElement;
}
private _closeDialog() {
if (!this._dialogElement)
return;
this._dialogElement.remove();
this._recorder.document.removeEventListener('keydown', this._keyboardListener!);
this._dialogElement = null;
}
}

View File

@ -114,6 +114,7 @@ export type AssertCheckedAction = ActionBase & {
};
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction;
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction;
// Signals.