diff --git a/packages/playwright-core/src/server/injected/highlight.css b/packages/playwright-core/src/server/injected/highlight.css
index 33ee63720f..b0275a303c 100644
--- a/packages/playwright-core/src/server/injected/highlight.css
+++ b/packages/playwright-core/src/server/injected/highlight.css
@@ -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,");
}
+x-pw-tool-item.value > x-div {
+ /* codicon: symbol-constant */
+ -webkit-mask-image: url("data:image/svg+xml;utf8,");
+ mask-image: url("data:image/svg+xml;utf8,");
+}
+
x-pw-tool-item.accept > x-div {
-webkit-mask-image: url("data:image/svg+xml;utf8,");
mask-image: url("data:image/svg+xml;utf8,");
diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts
index 8785783308..6ecd28fe03 100644
--- a/packages/playwright-core/src/server/injected/recorder.ts
+++ b/packages/playwright-core/src/server/injected/recorder.ts
@@ -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();
+ 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) {
diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts
index 4fc5a63cdf..bedcb7a691 100644
--- a/packages/playwright-core/src/server/recorder.ts
+++ b/packages/playwright-core/src/server/recorder.ts
@@ -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) {
diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx
index 4f73faeff5..69f8983c3f 100644
--- a/packages/recorder/src/recorder.tsx
+++ b/packages/recorder/src/recorder.tsx
@@ -128,15 +128,19 @@ export const Recorder: React.FC = ({
'recording-inspecting': 'recording',
'assertingText': 'recording-inspecting',
'assertingVisibility': 'recording-inspecting',
+ 'assertingValue': 'recording-inspecting',
}[mode];
window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { });
}}>
{
window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility' } });
}}>
- {
+ {
window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'recording' : 'assertingText' } });
}}>
+ {
+ window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingValue' ? 'recording' : 'assertingValue' } });
+ }}>
{
copy(source.text);
diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts
index e82e608554..c2b51549c4 100644
--- a/packages/recorder/src/recorderTypes.ts
+++ b/packages/recorder/src/recorderTypes.ts
@@ -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';