mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	chore: rework assert dialog (#28043)
This commit is contained in:
		
							parent
							
								
									5f527fedb1
								
							
						
					
					
						commit
						b004c1a0a7
					
				| @ -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; | ||||
| }`; | ||||
| } | ||||
| @ -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; | ||||
|  | ||||
| @ -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 | ||||
|     if (event.key === 'Escape') | ||||
|       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) { | ||||
|     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); | ||||
|   private _commit() { | ||||
|     if (!this._action || !this._dialogElement) | ||||
|       return; | ||||
|     this._closeDialog(); | ||||
|     this._recorder.delegate.recordAction?.(this._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 _showDialog() { | ||||
|     const target = this._hoverHighlight?.elements[0]; | ||||
|     if (!target) | ||||
|       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) | ||||
|     this._action = this._generateAction(); | ||||
|     if (!this._action) | ||||
|       return; | ||||
|     this._selectionHighlight = highlight; | ||||
|     this._selectionText = selectionText; | ||||
|     this._showHighlight(true); | ||||
|   } | ||||
| 
 | ||||
|   private _showHighlight(userGesture: boolean) { | ||||
|     const options: HighlightOptions = { | ||||
|       color: '#6fdcbd38', | ||||
|       tooltipText: this._generateActionPreview(), | ||||
|       toolbar: [this._acceptButton, this._cancelButton], | ||||
|       interactive: true, | ||||
|     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; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -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.
 | ||||
| 
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Pavel Feldman
						Pavel Feldman