feat(recorder): various UX fixes (#27967)

This commit is contained in:
Dmitry Gozman 2023-11-04 21:18:27 -07:00 committed by GitHub
parent 53a78a315e
commit ffd2e02aa3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 206 additions and 132 deletions

View File

@ -29,6 +29,7 @@ interface RecorderDelegate {
recordAction?(action: actions.Action): Promise<void>;
setSelector?(selector: string): Promise<void>;
setMode?(mode: Mode): Promise<void>;
setOverlayPosition?(position: { x: number, y: number }): Promise<void>;
highlightUpdated?(): void;
}
@ -441,7 +442,7 @@ class RecordActionTool implements RecorderTool {
class TextAssertionTool implements RecorderTool {
private _selectionHighlight: HighlightModel | null = null;
private _inputIsFocused = false;
private _inputHighlight: HighlightModel | null = null;
constructor(private _recorder: Recorder) {
}
@ -457,36 +458,15 @@ class TextAssertionTool implements RecorderTool {
disable() {
this._recorder.injectedScript.document.designMode = 'off';
this._selectionHighlight = null;
this._inputIsFocused = false;
this._inputHighlight = null;
}
onClick(event: MouseEvent) {
consumeEvent(event);
const target = this._recorder.deepEventTarget(event);
if (event.detail === 1 && ['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
const highlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes((target as HTMLInputElement).type.toLowerCase())) {
this._recorder.delegate.recordAction?.({
name: 'assertChecked',
selector: highlight.selector,
signals: [],
// Interestingly, inputElement.checked is reversed inside this event handler.
checked: !(target as HTMLInputElement).checked,
});
} else {
this._recorder.delegate.recordAction?.({
name: 'assertValue',
selector: highlight.selector,
signals: [],
value: target.isContentEditable ? target.innerText : (target as HTMLInputElement).value,
});
}
return;
}
const selection = this._recorder.document.getSelection();
if (event.detail === 1 && selection && !selection.toString()) {
if (event.detail === 1 && selection && !selection.toString() && !this._inputHighlight) {
const target = this._recorder.deepEventTarget(event);
selection.selectAllChildren(target);
this._updateSelectionHighlight();
}
@ -496,14 +476,13 @@ class TextAssertionTool implements RecorderTool {
const target = this._recorder.deepEventTarget(event);
if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
this._recorder.injectedScript.window.getSelection()?.empty();
this._selectionHighlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
this._inputIsFocused = true;
this._recorder.updateHighlight(this._selectionHighlight, true, '#6fdcbd38');
this._inputHighlight = generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName });
this._recorder.updateHighlight(this._inputHighlight, true, '#6fdcbd38');
consumeEvent(event);
return;
}
this._inputIsFocused = false;
this._inputHighlight = null;
this._updateSelectionHighlight();
}
@ -522,13 +501,35 @@ class TextAssertionTool implements RecorderTool {
onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
this._resetSelectionAndHighlight();
this._recorder.delegate.setMode?.('recording');
consumeEvent(event);
return;
}
if (event.key === 'Enter') {
const selection = this._recorder.document.getSelection();
if (selection && this._selectionHighlight) {
if (this._inputHighlight) {
const target = this._inputHighlight.elements[0] as HTMLInputElement;
if (target.nodeName === 'INPUT' && ['checkbox', 'radio'].includes(target.type.toLowerCase())) {
this._recorder.delegate.recordAction?.({
name: 'assertChecked',
selector: this._inputHighlight.selector,
signals: [],
// Interestingly, inputElement.checked is reversed inside this event handler.
checked: !(target as HTMLInputElement).checked,
});
this._recorder.delegate.setMode?.('recording');
} else {
this._recorder.delegate.recordAction?.({
name: 'assertValue',
selector: this._inputHighlight.selector,
signals: [],
value: target.value,
});
this._recorder.delegate.setMode?.('recording');
}
} else if (selection && this._selectionHighlight) {
const selectedText = normalizeWhiteSpace(selection.toString());
const fullText = normalizeWhiteSpace(elementText(new Map(), this._selectionHighlight.elements[0]).full);
this._recorder.delegate.recordAction?.({
@ -538,6 +539,7 @@ class TextAssertionTool implements RecorderTool {
text: selectedText,
substring: fullText !== selectedText,
});
this._recorder.delegate.setMode?.('recording');
this._resetSelectionAndHighlight();
}
consumeEvent(event);
@ -561,12 +563,13 @@ class TextAssertionTool implements RecorderTool {
private _resetSelectionAndHighlight() {
this._selectionHighlight = null;
this._inputHighlight = null;
this._recorder.injectedScript.window.getSelection()?.empty();
this._recorder.updateHighlight(null, false);
}
private _updateSelectionHighlight() {
if (this._inputIsFocused)
if (this._inputHighlight)
return;
const selection = this._recorder.document.getSelection();
let highlight: HighlightModel | null = null;
@ -586,7 +589,9 @@ class TextAssertionTool implements RecorderTool {
class Overlay {
private _overlayElement: HTMLElement;
private _tools: Record<Mode, HTMLElement>;
private _recordToggle: HTMLElement;
private _pickLocatorToggle: HTMLElement;
private _assertToggle: HTMLElement;
private _position: { x: number, y: number } = { x: 0, y: 0 };
private _dragState: { position: { x: number, y: number }, dragStart: { x: number, y: number } } | undefined;
private _measure: { width: number, height: number } = { width: 0, height: 0 };
@ -603,78 +608,77 @@ class Overlay {
max-width: min-content;
z-index: 2147483647;
background: transparent;
cursor: grab;
}
x-pw-tools-list {
box-shadow: rgba(0, 0, 0, 0.1) 0px 0.25em 0.5em;
box-shadow: rgba(0, 0, 0, 0.1) 0px 5px 5px;
backdrop-filter: blur(5px);
background-color: hsla(0 0% 100% / .9);
font-family: 'Dank Mono', 'Operator Mono', Inconsolata, 'Fira Mono',
'SF Mono', Monaco, 'Droid Sans Mono', 'Source Code Pro', monospace;
font-family: 'Dank Mono', 'Operator Mono', Inconsolata, 'Fira Mono', 'SF Mono', Monaco, 'Droid Sans Mono', 'Source Code Pro', monospace;
display: flex;
flex-direction: column;
margin: 1em;
padding: 0px;
border-radius: 2em;
margin: 10px;
padding: 3px 0;
border-radius: 17px;
}
x-pw-drag-handle {
cursor: grab;
height: 2px;
margin: 5px 9px;
border-top: 1px solid rgb(86 86 86 / 90%);
border-bottom: 1px solid rgb(86 86 86 / 90%);
}
x-pw-drag-handle:active {
cursor: grabbing;
}
x-pw-tool-item {
cursor: pointer;
height: 2.25em;
width: 2.25em;
margin: 0.05em 0.25em;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
height: 28px;
width: 28px;
margin: 2px 4px;
border-radius: 50%;
}
x-pw-tool-item:first-child {
margin-top: 0.25em;
}
x-pw-tool-item:last-child {
margin-bottom: 0.25em;
}
x-pw-tool-item:hover {
background-color: hsl(0, 0%, 95%);
}
x-pw-tool-item.active {
background-color: hsl(0, 0%, 100%);
x-pw-tool-item:not(.disabled):hover {
background-color: hsl(0, 0%, 86%);
}
x-pw-tool-item > div {
width: 100%;
height: 100%;
background-color: black;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: 20px;
mask-repeat: no-repeat;
mask-position: center;
mask-size: 20px;
mask-size: 16px;
background-color: #3a3a3a;
}
x-pw-tool-item.disabled > div {
background-color: rgba(97, 97, 97, 0.5);
cursor: default;
}
x-pw-tool-item.active > div {
background-color: #ff4ca5;
background-color: #006ab1;
}
x-pw-tool-item.none > div {
/* codicon: close */
-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='M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z'/></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='M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z'/></svg>");
x-pw-tool-item.record.active > div {
background-color: #a1260d;
}
x-pw-tool-item.inspecting > div {
/* codicon: target */
-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 9C8.55228 9 9 8.55228 9 8C9 7.44772 8.55228 7 8 7C7.44772 7 7 7.44772 7 8C7 8.55228 7.44772 9 8 9Z'/><path d='M12 8C12 10.2091 10.2091 12 8 12C5.79086 12 4 10.2091 4 8C4 5.79086 5.79086 4 8 4C10.2091 4 12 5.79086 12 8ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z'/><path d='M15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8ZM8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z'/></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 9C8.55228 9 9 8.55228 9 8C9 7.44772 8.55228 7 8 7C7.44772 7 7 7.44772 7 8C7 8.55228 7.44772 9 8 9Z'/><path d='M12 8C12 10.2091 10.2091 12 8 12C5.79086 12 4 10.2091 4 8C4 5.79086 5.79086 4 8 4C10.2091 4 12 5.79086 12 8ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z'/><path d='M15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8ZM8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z'/></svg>");
x-pw-tool-item.record > 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.recording > div {
/* codicon: record */
-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 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z'/><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>");
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 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z'/><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>");
x-pw-tool-item.pick-locator > 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.assertingText > div {
/* codicon: text-size */
-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='M3.36 7L1 13h1.34l.51-1.47h2.26L5.64 13H7L4.65 7H3.36zm-.15 3.53l.78-2.14.78 2.14H3.21zM11.82 4h-1.6L7 13h1.56l.75-2.29h3.36l.77 2.29H15l-3.18-9zM9.67 9.5l1.18-3.59c.059-.185.1-.376.12-.57.027.192.064.382.11.57l1.25 3.59H9.67z'/></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='M3.36 7L1 13h1.34l.51-1.47h2.26L5.64 13H7L4.65 7H3.36zm-.15 3.53l.78-2.14.78 2.14H3.21zM11.82 4h-1.6L7 13h1.56l.75-2.29h3.36l.77 2.29H15l-3.18-9zM9.67 9.5l1.18-3.59c.059-.185.1-.376.12-.57.027.192.064.382.11.57l1.25 3.59H9.67z'/></svg>");
x-pw-tool-item.assert > 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>");
}
`;
shadow.appendChild(styleElement);
@ -682,34 +686,50 @@ class Overlay {
const toolsListElement = document.createElement('x-pw-tools-list');
shadow.appendChild(toolsListElement);
this._tools = {
none: this._createToolElement(toolsListElement, 'none', 'Disable'),
inspecting: this._createToolElement(toolsListElement, 'inspecting', 'Pick locator'),
recording: this._createToolElement(toolsListElement, 'recording', 'Record actions'),
assertingText: this._createToolElement(toolsListElement, 'assertingText', 'Assert text and values'),
};
this._recordToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
this._recordToggle.title = 'Record';
this._recordToggle.classList.add('record');
this._recordToggle.appendChild(this._recorder.injectedScript.document.createElement('div'));
this._recordToggle.addEventListener('click', () => {
this._recorder.delegate.setMode?.(this._recorder.state.mode === 'none' || this._recorder.state.mode === 'inspecting' ? 'recording' : 'none');
});
toolsListElement.appendChild(this._recordToggle);
this._overlayElement.addEventListener('mousedown', event => {
const dragHandle = document.createElement('x-pw-drag-handle');
dragHandle.addEventListener('mousedown', event => {
this._dragState = { position: this._position, dragStart: { x: event.clientX, y: event.clientY } };
});
toolsListElement.appendChild(dragHandle);
this._pickLocatorToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
this._pickLocatorToggle.title = 'Pick locator';
this._pickLocatorToggle.classList.add('pick-locator');
this._pickLocatorToggle.appendChild(this._recorder.injectedScript.document.createElement('div'));
this._pickLocatorToggle.addEventListener('click', () => {
const newMode: Record<Mode, Mode> = {
'inspecting': 'none',
'none': 'inspecting',
'recording': 'recording-inspecting',
'recording-inspecting': 'recording',
'assertingText': 'recording-inspecting',
};
this._recorder.delegate.setMode?.(newMode[this._recorder.state.mode]);
});
toolsListElement.appendChild(this._pickLocatorToggle);
this._assertToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
this._assertToggle.title = 'Assert text and values';
this._assertToggle.classList.add('assert');
this._assertToggle.appendChild(this._recorder.injectedScript.document.createElement('div'));
this._assertToggle.addEventListener('click', () => {
if (!this._assertToggle.classList.contains('disabled'))
this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingText' ? 'recording' : 'assertingText');
});
toolsListElement.appendChild(this._assertToggle);
if (this._recorder.injectedScript.isUnderTest) {
// Most of our tests put elements at the top left, so get out of the way.
this._position = { x: 350, y: 350 };
}
this._updateVisualPosition();
}
private _createToolElement(parent: Element, mode: Mode, title: string) {
const element = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
element.title = title;
element.classList.add(mode);
element.appendChild(this._recorder.injectedScript.document.createElement('div'));
element.addEventListener('click', () => this._recorder.delegate.setMode?.(mode));
parent.appendChild(element);
return element;
}
install() {
this._recorder.injectedScript.document.documentElement.appendChild(this._overlayElement);
this._measure = this._overlayElement.getBoundingClientRect();
@ -720,8 +740,14 @@ class Overlay {
}
setUIState(state: UIState) {
for (const [mode, tool] of Object.entries(this._tools))
tool.classList.toggle('active', state.mode === mode);
this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'recording-inspecting');
this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting');
this._assertToggle.classList.toggle('active', state.mode === 'assertingText');
this._assertToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'inspecting');
if (this._position.x !== state.overlayPosition.x || this._position.y !== state.overlayPosition.y) {
this._position = state.overlayPosition;
this._updateVisualPosition();
}
}
private _updateVisualPosition() {
@ -742,6 +768,7 @@ class Overlay {
this._position.x = Math.max(0, Math.min(this._recorder.injectedScript.window.innerWidth - this._measure.width, this._position.x));
this._position.y = Math.max(0, Math.min(this._recorder.injectedScript.window.innerHeight - this._measure.height, this._position.y));
this._updateVisualPosition();
this._recorder.delegate.setOverlayPosition?.(this._position);
consumeEvent(event);
return true;
}
@ -767,7 +794,7 @@ export class Recorder {
private _highlight: Highlight;
private _overlay: Overlay | undefined;
private _styleElement: HTMLStyleElement;
state: UIState = { mode: 'none', testIdAttributeName: 'data-testid', language: 'javascript' };
state: UIState = { mode: 'none', testIdAttributeName: 'data-testid', language: 'javascript', overlayPosition: { x: 0, y: 0 } };
readonly document: Document;
delegate: RecorderDelegate = {};
@ -776,10 +803,11 @@ export class Recorder {
this.injectedScript = injectedScript;
this._highlight = new Highlight(injectedScript);
this._tools = {
none: new NoneTool(),
inspecting: new InspectTool(this),
recording: new RecordActionTool(this),
assertingText: new TextAssertionTool(this),
'none': new NoneTool(),
'inspecting': new InspectTool(this),
'recording': new RecordActionTool(this),
'recording-inspecting': new InspectTool(this),
'assertingText': new TextAssertionTool(this),
};
this._currentTool = this._tools.none;
if (injectedScript.window.top === injectedScript.window) {
@ -1074,6 +1102,7 @@ interface Embedder {
__pw_recorderState(): Promise<UIState>;
__pw_recorderSetSelector(selector: string): Promise<void>;
__pw_recorderSetMode(mode: Mode): Promise<void>;
__pw_recorderSetOverlayPosition(position: { x: number, y: number }): Promise<void>;
__pw_refreshOverlay(): void;
}
@ -1122,10 +1151,6 @@ export class PollingRecorder implements RecorderDelegate {
await this._embedder.__pw_recorderRecordAction(action);
}
async __pw_recorderState(): Promise<UIState> {
return await this._embedder.__pw_recorderState();
}
async setSelector(selector: string): Promise<void> {
await this._embedder.__pw_recorderSetSelector(selector);
}
@ -1133,6 +1158,10 @@ export class PollingRecorder implements RecorderDelegate {
async setMode(mode: Mode): Promise<void> {
await this._embedder.__pw_recorderSetMode(mode);
}
async setOverlayPosition(position: { x: number, y: number }): Promise<void> {
await this._embedder.__pw_recorderSetOverlayPosition(position);
}
}
export default PollingRecorder;

View File

@ -55,6 +55,7 @@ export class Recorder implements InstrumentationListener {
private _context: BrowserContext;
private _mode: Mode;
private _highlightedSelector = '';
private _overlayPosition: Point = { x: 0, y: 0 };
private _recorderApp: IRecorderApp | null = null;
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
private _recorderSources: Source[] = [];
@ -97,6 +98,11 @@ export class Recorder implements InstrumentationListener {
this._handleSIGINT = params.handleSIGINT;
context.instrumentation.addListener(this, context);
this._currentLanguage = this._contextRecorder.languageName();
if (isUnderTest()) {
// Most of our tests put elements at the top left, so get out of the way.
this._overlayPosition = { x: 350, y: 350 };
}
}
private static async defaultRecorderAppFactory(recorder: Recorder) {
@ -180,6 +186,7 @@ export class Recorder implements InstrumentationListener {
actionSelector,
language: this._currentLanguage,
testIdAttributeName: this._contextRecorder.testIdAttributeName(),
overlayPosition: this._overlayPosition,
};
return uiState;
});
@ -202,6 +209,12 @@ export class Recorder implements InstrumentationListener {
this.setMode(mode);
});
await this._context.exposeBinding('__pw_recorderSetOverlayPosition', false, async ({ frame }, position: Point) => {
if (frame.parentFrame())
return;
this._overlayPosition = position;
});
await this._context.exposeBinding('__pw_resume', false, () => {
this._debugger.resume(false);
});
@ -244,6 +257,10 @@ export class Recorder implements InstrumentationListener {
this._debugger.resume(false);
}
mode() {
return this._mode;
}
setHighlightedSelector(language: Language, selector: string) {
this._highlightedSelector = locatorOrSelectorAsSelector(language, selector, this._context.selectors().testIdAttributeName());
this._refreshOverlay();

View File

@ -46,7 +46,7 @@ export interface IRecorderApp extends EventEmitter {
setPaused(paused: boolean): Promise<void>;
setMode(mode: Mode): Promise<void>;
setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, focus?: boolean): Promise<void>;
setSelector(selector: string, userGesture?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
setSources(sources: Source[]): Promise<void>;
}
@ -56,7 +56,7 @@ export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: Mode): Promise<void> {}
async setFileIfNeeded(file: string): Promise<void> {}
async setSelector(selector: string, focus?: boolean): Promise<void> {}
async setSelector(selector: string, userGesture?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
async setSources(sources: Source[]): Promise<void> {}
}
@ -166,14 +166,18 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
(process as any)._didSetSourcesForTest(sources[0].text);
}
async setSelector(selector: string, focus?: boolean): Promise<void> {
if (focus) {
this._recorder.setMode('none');
this._page.bringToFront();
async setSelector(selector: string, userGesture?: boolean): Promise<void> {
if (userGesture) {
if (this._recorder.mode() === 'inspecting') {
this._recorder.setMode('none');
this._page.bringToFront();
} else {
this._recorder.setMode('recording');
}
}
await this._page.mainFrame().evaluateExpression(((arg: any) => {
window.playwrightSetSelector(arg.selector, arg.focus);
}).toString(), { isFunction: true }, { selector, focus }).catch(() => {});
await this._page.mainFrame().evaluateExpression(((selector: string) => {
window.playwrightSetSelector(selector);
}).toString(), { isFunction: true }, selector).catch(() => {});
}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {

View File

@ -28,13 +28,11 @@
min-width: 100px;
}
.recorder .toolbar-button.toggled.record,
.recorder .toolbar-button.toggled.text-size {
.recorder .toolbar-button.toggled.circle-large-filled {
color: #a1260d;
}
body.dark-mode .recorder .toolbar-button.toggled.record,
body.dark-mode .recorder .toolbar-button.toggled.text-size {
body.dark-mode .recorder .toolbar-button.toggled.circle-large-filled {
color: #f48771;
}

View File

@ -19,7 +19,7 @@ import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { SplitView } from '@web/components/splitView';
import { TabbedPane } from '@web/components/tabbedPane';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton } from '@web/components/toolbarButton';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
import * as React from 'react';
import { CallLogView } from './callLog';
import './recorder.css';
@ -66,7 +66,7 @@ export const Recorder: React.FC<RecorderProps> = ({
};
const [locator, setLocator] = React.useState('');
window.playwrightSetSelector = (selector: string, focus?: boolean) => {
window.playwrightSetSelector = (selector: string) => {
const language = source.language;
setLocator(asLocator(language, selector));
};
@ -113,12 +113,25 @@ export const Recorder: React.FC<RecorderProps> = ({
return <div className='recorder'>
<Toolbar>
<ToolbarButton icon='record' title='Record actions' toggled={mode === 'recording'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' } });
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording' || mode === 'recording-inspecting' || mode === 'assertingText'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'none' || mode === 'inspecting' ? 'recording' : 'none' } });
}}>Record</ToolbarButton>
<ToolbarButton icon='text-size' title='Assert text and values' toggled={mode === 'assertingText'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'none' : 'assertingText' } });
<ToolbarSeparator />
<ToolbarButton icon='inspect' title='Pick locator' toggled={mode === 'inspecting' || mode === 'recording-inspecting'} onClick={() => {
const newMode = {
'inspecting': 'none',
'none': 'inspecting',
'recording': 'recording-inspecting',
'recording-inspecting': 'recording',
'assertingText': 'recording-inspecting',
}[mode];
window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { });
setSelectedTab('locator');
}}>Pick locator</ToolbarButton>
<ToolbarButton icon='check-all' title='Assert text and values' toggled={mode === 'assertingText'} disabled={mode === 'none' || mode === 'inspecting'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'recording' : 'assertingText' } });
}}>Assert</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
copy(source.text);
}}></ToolbarButton>
@ -145,10 +158,6 @@ export const Recorder: React.FC<RecorderProps> = ({
<SplitView sidebarSize={200} sidebarHidden={mode === 'recording'}>
<CodeMirrorWrapper text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine} readOnly={true} lineNumbers={true}/>
<TabbedPane
leftToolbar={[<ToolbarButton icon='target' title='Pick locator' toggled={mode === 'inspecting'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' } }).catch(() => { });
setSelectedTab('locator');
}} />]}
rightToolbar={selectedTab === 'locator' ? [<ToolbarButton icon='files' title='Copy' onClick={() => copy(locator)} />] : []}
tabs={[
{

View File

@ -18,7 +18,7 @@ import type { Language } from '../../playwright-core/src/utils/isomorphic/locato
export type Point = { x: number, y: number };
export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText';
export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText' | 'recording-inspecting';
export type EventData = {
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'setRecordingTool' | 'selectorUpdated' | 'fileChanged';
@ -31,6 +31,7 @@ export type UIState = {
actionSelector?: string;
language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
testIdAttributeName: string;
overlayPosition: Point;
};
export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused';

View File

@ -242,6 +242,7 @@ export const InspectModeController: React.FunctionComponent<{
actionSelector: actionSelector.startsWith(frameSelector) ? actionSelector.substring(frameSelector.length).trim() : undefined,
language: sdkLanguage,
testIdAttributeName,
overlayPosition: { x: 0, y: 0 },
}, {
async setSelector(selector: string) {
setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector, false /* isFrameLocator */, true /* playSafe */));

View File

@ -46,3 +46,12 @@
.toolbar-button.toggled .codicon {
font-weight: bold;
}
.toolbar-separator {
flex: none;
background-color: var(--vscode-menu-separatorBackground);
width: 1px;
padding: 0;
margin: 5px 4px;
height: 16px;
}

View File

@ -53,6 +53,12 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
</button>;
};
export const ToolbarSeparator: React.FC<{ style?: React.CSSProperties }> = ({
style,
}) => {
return <div className='toolbar-separator' style={style}></div>;
};
const preventDefault = (e: any) => {
e.stopPropagation();
e.preventDefault();