feat(recorder): assert value as a separate tool (#28145)

This commit is contained in:
Dmitry Gozman 2023-11-14 15:17:42 -08:00 committed by GitHub
parent f76c261b16
commit 557f3afd74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 95 additions and 20 deletions

View File

@ -55,6 +55,13 @@ x-pw-dialog-body {
flex: auto;
}
x-pw-dialog-body label {
margin: 5px 8px;
display: flex;
flex-direction: row;
align-items: center;
}
x-pw-highlight {
position: absolute;
top: 0;
@ -129,6 +136,14 @@ x-pw-tool-item:not(.disabled):hover {
background-color: hsl(0, 0%, 86%);
}
x-pw-tool-item.active {
background-color: rgba(138, 202, 228, 0.5);
}
x-pw-tool-item.active:not(.disabled):hover {
background-color: #8acae4c4;
}
x-pw-tool-item > x-div {
width: 100%;
height: 100%;
@ -146,8 +161,12 @@ x-pw-tool-item.disabled > x-div {
cursor: default;
}
x-pw-tool-item.active > x-div {
background-color: #006ab1;
x-pw-tool-item.record.active {
background-color: transparent;
}
x-pw-tool-item.record.active:hover {
background-color: hsl(0, 0%, 86%);
}
x-pw-tool-item.record.active > x-div {
@ -182,6 +201,12 @@ x-pw-tool-item.visibility > x-div {
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='M7.99993 6.00316C9.47266 6.00316 10.6666 7.19708 10.6666 8.66981C10.6666 10.1426 9.47266 11.3365 7.99993 11.3365C6.52715 11.3365 5.33324 10.1426 5.33324 8.66981C5.33324 7.19708 6.52715 6.00316 7.99993 6.00316ZM7.99993 7.00315C7.07946 7.00315 6.33324 7.74935 6.33324 8.66981C6.33324 9.59028 7.07946 10.3365 7.99993 10.3365C8.9204 10.3365 9.6666 9.59028 9.6666 8.66981C9.6666 7.74935 8.9204 7.00315 7.99993 7.00315ZM7.99993 3.66675C11.0756 3.66675 13.7307 5.76675 14.4673 8.70968C14.5344 8.97755 14.3716 9.24908 14.1037 9.31615C13.8358 9.38315 13.5643 9.22041 13.4973 8.95248C12.8713 6.45205 10.6141 4.66675 7.99993 4.66675C5.38454 4.66675 3.12664 6.45359 2.50182 8.95555C2.43491 9.22341 2.16348 9.38635 1.89557 9.31948C1.62766 9.25255 1.46471 8.98115 1.53162 8.71321C2.26701 5.76856 4.9229 3.66675 7.99993 3.66675Z'/></svg>");
}
x-pw-tool-item.value > x-div {
/* codicon: symbol-constant */
-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='M4 6h8v1H4V6zm8 3H4v1h8V9z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z'/></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='M4 6h8v1H4V6zm8 3H4v1h8V9z'/><path fill-rule='evenodd' clip-rule='evenodd' d='M1 4l1-1h12l1 1v8l-1 1H2l-1-1V4zm1 0v8h12V4H2z'/></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>");

View File

@ -80,10 +80,14 @@ class NoneTool implements RecorderTool {
}
class InspectTool implements RecorderTool {
private _recorder: Recorder;
private _hoveredModel: HighlightModel | null = null;
private _hoveredElement: HTMLElement | null = null;
private _assertVisibility: boolean;
constructor(private _recorder: Recorder, private _assertVisibility: boolean) {
constructor(recorder: Recorder, assertVisibility: boolean) {
this._recorder = recorder;
this._assertVisibility = assertVisibility;
}
cursor() {
@ -104,6 +108,7 @@ class InspectTool implements RecorderTool {
selector: this._hoveredModel.selector,
signals: [],
});
this._recorder.delegate.setMode?.('recording');
}
} else {
this._recorder.delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : '');
@ -138,7 +143,7 @@ class InspectTool implements RecorderTool {
if (this._hoveredModel?.selector === model?.selector)
return;
this._hoveredModel = model;
this._recorder.updateHighlight(model, true);
this._recorder.updateHighlight(model, true, { color: this._assertVisibility ? '#8acae480' : undefined });
}
onMouseLeave(event: MouseEvent) {
@ -170,13 +175,15 @@ class InspectTool implements RecorderTool {
}
class RecordActionTool implements RecorderTool {
private _recorder: Recorder;
private _performingAction = false;
private _hoveredModel: HighlightModel | null = null;
private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null;
private _expectProgrammaticKeyUp = false;
constructor(private _recorder: Recorder) {
constructor(recorder: Recorder) {
this._recorder = recorder;
}
cursor() {
@ -474,6 +481,7 @@ class RecordActionTool implements RecorderTool {
}
class TextAssertionTool implements RecorderTool {
private _recorder: Recorder;
private _hoverHighlight: HighlightModel | null = null;
private _action: actions.AssertAction | null = null;
private _dialogElement: HTMLElement | null = null;
@ -481,8 +489,12 @@ class TextAssertionTool implements RecorderTool {
private _cancelButton: HTMLElement;
private _keyboardListener: ((event: KeyboardEvent) => void) | undefined;
private _textCache = new Map<Element | ShadowRoot, ElementText>();
private _kind: 'text' | 'value';
constructor(recorder: Recorder, kind: 'text' | 'value') {
this._recorder = recorder;
this._kind = kind;
constructor(private _recorder: Recorder) {
this._acceptButton = this._recorder.document.createElement('x-pw-tool-item');
this._acceptButton.title = 'Accept';
this._acceptButton.classList.add('accept');
@ -523,7 +535,10 @@ class TextAssertionTool implements RecorderTool {
const target = this._recorder.deepEventTarget(event);
if (this._hoverHighlight?.elements[0] === target)
return;
this._hoverHighlight = target.nodeName === 'INPUT' || target.nodeName === 'TEXTAREA' || elementText(new Map(), target).full ? { elements: [target], selector: '' } : null;
if (this._kind === 'text')
this._hoverHighlight = elementText(this._textCache, target).full ? { elements: [target], selector: '' } : null;
else
this._hoverHighlight = this._elementHasValue(target) ? generateSelector(this._recorder.injectedScript, target, { testIdAttributeName: this._recorder.state.testIdAttributeName }) : null;
this._recorder.updateHighlight(this._hoverHighlight, true, { color: '#8acae480' });
}
@ -533,12 +548,22 @@ class TextAssertionTool implements RecorderTool {
consumeEvent(event);
}
onScroll(event: Event) {
this._recorder.updateHighlight(this._hoverHighlight, false, { color: '#8acae480' });
}
private _elementHasValue(element: Element) {
return element.nodeName === 'TEXTAREA' || element.nodeName === 'SELECT' || (element.nodeName === 'INPUT' && !['button', 'image', 'reset', 'submit'].includes((element as HTMLInputElement).type));
}
private _generateAction(): actions.AssertAction | null {
this._textCache.clear();
const target = this._hoverHighlight?.elements[0];
if (!target)
return null;
if (target.nodeName === 'INPUT' || target.nodeName === 'TEXTAREA' || target.nodeName === 'SELECT') {
if (this._kind === 'value') {
if (!this._elementHasValue(target))
return null;
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 {
@ -553,7 +578,7 @@ class TextAssertionTool implements RecorderTool {
name: 'assertValue',
selector,
signals: [],
value: (target as (HTMLInputElement | HTMLSelectElement)).value,
value: (target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)).value,
};
}
} else {
@ -637,7 +662,11 @@ class TextAssertionTool implements RecorderTool {
cm.on('change', () => {
if (this._action) {
const selector = locatorOrSelectorAsSelector(this._recorder.state.language, cm.getValue(), this._recorder.state.testIdAttributeName);
const elements = this._recorder.injectedScript.querySelectorAll(parseSelector(selector), this._recorder.document);
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,
@ -735,16 +764,19 @@ class TextAssertionTool implements RecorderTool {
}
class Overlay {
private _recorder: Recorder;
private _overlayElement: HTMLElement;
private _recordToggle: HTMLElement;
private _pickLocatorToggle: HTMLElement;
private _assertVisibilityToggle: HTMLElement;
private _assertTextToggle: HTMLElement;
private _assertValuesToggle: HTMLElement;
private _offsetX = 0;
private _dragState: { offsetX: number, dragStart: { x: number, y: number } } | undefined;
private _measure: { width: number, height: number } = { width: 0, height: 0 };
constructor(private _recorder: Recorder) {
constructor(recorder: Recorder) {
this._recorder = recorder;
const document = this._recorder.injectedScript.document;
this._overlayElement = document.createElement('x-pw-overlay');
@ -780,6 +812,7 @@ class Overlay {
'recording-inspecting': 'recording',
'assertingText': 'recording-inspecting',
'assertingVisibility': 'recording-inspecting',
'assertingValue': 'recording-inspecting',
};
this._recorder.delegate.setMode?.(newMode[this._recorder.state.mode]);
});
@ -805,6 +838,16 @@ class Overlay {
});
toolsListElement.appendChild(this._assertTextToggle);
this._assertValuesToggle = this._recorder.injectedScript.document.createElement('x-pw-tool-item');
this._assertValuesToggle.title = 'Assert value';
this._assertValuesToggle.classList.add('value');
this._assertValuesToggle.appendChild(this._recorder.injectedScript.document.createElement('x-div'));
this._assertValuesToggle.addEventListener('click', () => {
if (!this._assertValuesToggle.classList.contains('disabled'))
this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue');
});
toolsListElement.appendChild(this._assertValuesToggle);
this._updateVisualPosition();
}
@ -818,12 +861,14 @@ class Overlay {
}
setUIState(state: UIState) {
this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'recording-inspecting');
this._recordToggle.classList.toggle('active', state.mode === 'recording' || state.mode === 'assertingText' || state.mode === 'assertingVisibility' || state.mode === 'assertingValue' || state.mode === 'recording-inspecting');
this._pickLocatorToggle.classList.toggle('active', state.mode === 'inspecting' || state.mode === 'recording-inspecting');
this._assertVisibilityToggle.classList.toggle('active', state.mode === 'assertingVisibility');
this._assertVisibilityToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
this._assertTextToggle.classList.toggle('active', state.mode === 'assertingText');
this._assertTextToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
this._assertValuesToggle.classList.toggle('active', state.mode === 'assertingValue');
this._assertValuesToggle.classList.toggle('disabled', state.mode === 'none' || state.mode === 'standby' || state.mode === 'inspecting');
if (this._offsetX !== state.overlay.offsetX) {
this._offsetX = state.overlay.offsetX;
this._updateVisualPosition();
@ -896,8 +941,9 @@ export class Recorder {
'inspecting': new InspectTool(this, false),
'recording': new RecordActionTool(this),
'recording-inspecting': new InspectTool(this, false),
'assertingText': new TextAssertionTool(this),
'assertingText': new TextAssertionTool(this, 'text'),
'assertingVisibility': new InspectTool(this, true),
'assertingValue': new TextAssertionTool(this, 'value'),
};
this._currentTool = this._tools.none;
if (injectedScript.window.top === injectedScript.window) {

View File

@ -246,8 +246,8 @@ export class Recorder implements InstrumentationListener {
this._highlightedSelector = '';
this._mode = mode;
this._recorderApp?.setMode(this._mode);
this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility');
this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility');
this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue');
this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue');
if (this._mode !== 'none' && this._mode !== 'standby' && this._context.pages().length === 1)
this._context.pages()[0].bringToFront().catch(() => {});
this._refreshOverlay();
@ -281,7 +281,7 @@ export class Recorder implements InstrumentationListener {
}
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {
if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility')
if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue')
return;
this._currentCallsMetadata.set(metadata, sdkObject);
this._updateUserSources();
@ -295,7 +295,7 @@ export class Recorder implements InstrumentationListener {
}
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) {
if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility')
if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue')
return;
if (!metadata.error)
this._currentCallsMetadata.delete(metadata);
@ -345,7 +345,7 @@ export class Recorder implements InstrumentationListener {
}
updateCallLog(metadatas: CallMetadata[]) {
if (this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility')
if (this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue')
return;
const logs: CallLog[] = [];
for (const metadata of metadatas) {

View File

@ -128,15 +128,19 @@ export const Recorder: React.FC<RecorderProps> = ({
'recording-inspecting': 'recording',
'assertingText': 'recording-inspecting',
'assertingVisibility': 'recording-inspecting',
'assertingValue': 'recording-inspecting',
}[mode];
window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { });
}}></ToolbarButton>
<ToolbarButton icon='eye' title='Assert visibility' toggled={mode === 'assertingVisibility'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility' } });
}}></ToolbarButton>
<ToolbarButton icon='whole-word' title='Assert text and values' toggled={mode === 'assertingText'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
<ToolbarButton icon='whole-word' title='Assert text' toggled={mode === 'assertingText'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'recording' : 'assertingText' } });
}}></ToolbarButton>
<ToolbarButton icon='symbol-constant' title='Assert value' toggled={mode === 'assertingValue'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingValue' ? 'recording' : 'assertingValue' } });
}}></ToolbarButton>
<ToolbarSeparator />
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
copy(source.text);

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' | 'recording-inspecting' | 'standby' | 'assertingVisibility';
export type Mode = 'inspecting' | 'recording' | 'none' | 'assertingText' | 'recording-inspecting' | 'standby' | 'assertingVisibility' | 'assertingValue';
export type EventData = {
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'fileChanged';