diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md
index 9209694046593..a3b42b741a6dd 100644
--- a/docs/src/release-notes-csharp.md
+++ b/docs/src/release-notes-csharp.md
@@ -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`].
diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md
index 041b390c090b4..5703cf9a26df4 100644
--- a/docs/src/release-notes-java.md
+++ b/docs/src/release-notes-java.md
@@ -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`].
diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md
index 38a06d014949d..7a7ad52bb9088 100644
--- a/docs/src/release-notes-js.md
+++ b/docs/src/release-notes-js.md
@@ -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`].
diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md
index ada5a664bfc5d..f3388431f3847 100644
--- a/docs/src/release-notes-python.md
+++ b/docs/src/release-notes-python.md
@@ -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`].
diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css
index b0275a303cd82..e45e45b55ea86 100644
--- a/packages/playwright-core/src/server/injected/highlight.css
+++ b/packages/playwright-core/src/server/injected/highlight.css
@@ -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,");
}
+x-pw-tool-item.succeeded > x-div {
+ /* codicon: pass */
+ -webkit-mask-image: url("data:image/svg+xml;utf8,") !important;
+ mask-image: url("data:image/svg+xml;utf8,") !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 {
diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts
index 6ecd28fe039d8..f88bddd528596 100644
--- a/packages/playwright-core/src/server/injected/recorder.ts
+++ b/packages/playwright-core/src/server/injected/recorder.ts
@@ -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;
recordAction?(action: actions.Action): Promise;
@@ -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) {
- if (!this._dialogElement)
- this._showDialog();
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();
+ }
}
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,122 +631,41 @@ 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);
- textElement.classList.add('text-editor');
-
- const updateAndValidate = () => {
- const newValue = normalizeWhiteSpace(textElement.value);
- const target = this._hoverHighlight?.elements[0];
- if (!target)
- return;
- action.text = newValue;
- const targetText = normalizeWhiteSpace(elementText(this._textCache, target).full);
- const matches = action.substring ? newValue && targetText.includes(newValue) : targetText === 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;
- }
+ const textElement = this._recorder.document.createElement('textarea');
+ textElement.setAttribute('spellcheck', 'false');
+ textElement.value = this._renderValue(this._action);
+ textElement.classList.add('text-editor');
+
+ const updateAndValidate = () => {
+ const newValue = normalizeWhiteSpace(textElement.value);
+ const target = this._hoverHighlight?.elements[0];
+ if (!target)
+ return;
+ action.text = newValue;
+ const targetText = normalizeWhiteSpace(elementText(this._textCache, target).full);
+ const matches = newValue && targetText.includes(newValue);
+ textElement.classList.toggle('does-not-match', !matches);
+ };
+ textElement.addEventListener('input', updateAndValidate);
+ 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';
- 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._overlayElement.removeAttribute('hidden');
+ 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;
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;