diff --git a/packages/playwright-core/src/server/injected/domUtils.ts b/packages/playwright-core/src/server/injected/domUtils.ts index cf888248c4dcd..1b4922c91364f 100644 --- a/packages/playwright-core/src/server/injected/domUtils.ts +++ b/packages/playwright-core/src/server/injected/domUtils.ts @@ -23,6 +23,12 @@ export function isInsideScope(scope: Node, element: Element | undefined): boolea return false; } +export function enclosingElement(node: Node) { + if (node.nodeType === 1 /* Node.ELEMENT_NODE */) + return node as Element; + return node.parentElement ?? undefined; +} + export function parentElementOrShadowHost(element: Element): Element | undefined { if (element.parentElement) return element.parentElement; diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts index 9c3aa5eecb957..fdd5e9f1f85b7 100644 --- a/packages/playwright-core/src/server/injected/highlight.ts +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -112,7 +112,7 @@ export class Highlight { runHighlightOnRaf(selector: ParsedSelector) { if (this._rafRequest) cancelAnimationFrame(this._rafRequest); - this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), stringifySelector(selector), false); + this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), stringifySelector(selector)); this._rafRequest = requestAnimationFrame(() => this.runHighlightOnRaf(selector)); } @@ -144,11 +144,8 @@ export class Highlight { this._highlightEntries = []; } - updateHighlight(elements: Element[], selector: string, isRecording: boolean) { - let color: string; - if (isRecording) - color = '#dc6f6f7f'; - else + updateHighlight(elements: Element[], selector: string, color?: string) { + if (!color) color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f'; this._innerUpdateHighlight(elements, { color, tooltipText: selector ? asLocator(this._language, selector) : '' }); } diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 734cbf0fcbbc6..ede07db25a89f 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -18,8 +18,11 @@ import type * as actions from '../recorder/recorderActions'; import type { InjectedScript } from '../injected/injectedScript'; import { generateSelector } from '../injected/selectorGenerator'; import type { Point } from '../../common/types'; -import type { UIState } from '@recorder/recorderTypes'; +import type { UIState, Mode, RecordingTool } from '@recorder/recorderTypes'; import { Highlight } from '../injected/highlight'; +import { enclosingElement, isInsideScope, parentElementOrShadowHost } from './domUtils'; +import { elementText } from './selectorUtils'; +import { normalizeWhiteSpace } from '@isomorphic/stringUtils'; interface RecorderDelegate { performAction?(action: actions.Action): Promise; @@ -36,7 +39,9 @@ export class Recorder { private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModel | null = null; private _expectProgrammaticKeyUp = false; - private _mode: 'none' | 'inspecting' | 'recording' = 'none'; + private _mode: Mode = 'none'; + private _tool: RecordingTool = 'action'; + private _selectionModel: SelectionModel | undefined; private _actionPoint: Point | undefined; private _actionSelector: string | undefined; private _highlight: Highlight; @@ -93,11 +98,12 @@ export class Recorder { else this.uninstallListeners(); - const { mode, actionPoint, actionSelector, language, testIdAttributeName } = state; + const { mode, tool, actionPoint, actionSelector, language, testIdAttributeName } = state; this._testIdAttributeName = testIdAttributeName; this._highlight.setLanguage(language); - if (mode !== this._mode) { + if (mode !== this._mode || this._tool !== tool) { this._mode = mode; + this._tool = tool; this.clearHighlight(); } if (actionPoint && this._actionPoint && actionPoint.x === this._actionPoint.x && actionPoint.y === this._actionPoint.y) { @@ -126,6 +132,10 @@ export class Recorder { clearHighlight() { this._hoveredModel = null; this._activeModel = null; + if (this._selectionModel) { + this._selectionModel = undefined; + this._syncDocumentSelection(); + } this._updateHighlight(false); } @@ -157,6 +167,19 @@ export class Recorder { return; if (this._mode === 'inspecting') this._delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : ''); + if (this._mode === 'recording' && this._tool === 'assert') { + if (event.detail === 1 && !this._getSelectionText()) { + const target = this._deepEventTarget(event); + const text = target ? elementText(this._injectedScript._evaluator._cacheText, target).full : ''; + if (text) { + this._selectionModel = { anchor: { node: target, offset: 0 }, focus: { node: target, offset: target.childNodes.length } }; + this._syncDocumentSelection(); + this._updateSelectionHighlight(); + } + } + consumeEvent(event); + return; + } if (this._shouldIgnoreMouseEvent(event)) return; if (this._actionInProgress(event)) @@ -202,11 +225,32 @@ export class Recorder { return false; } + private _selectionPosition(event: MouseEvent) { + if ((this.document as any).caretPositionFromPoint) { + const range = (this.document as any).caretPositionFromPoint(event.clientX, event.clientY); + return range ? { node: range.offsetNode, offset: range.offset } : undefined; + } + if ((this.document as any).caretRangeFromPoint) { + const range = this.document.caretRangeFromPoint(event.clientX, event.clientY); + return range ? { node: range.startContainer, offset: range.startOffset } : undefined; + } + } + private _onMouseDown(event: MouseEvent) { if (!event.isTrusted) return; if (this._shouldIgnoreMouseEvent(event)) return; + if (this._mode === 'recording' && this._tool === 'assert') { + const pos = this._selectionPosition(event); + if (pos && event.detail <= 1) { + this._selectionModel = { anchor: pos, focus: pos }; + this._syncDocumentSelection(); + this._updateSelectionHighlight(); + } + consumeEvent(event); + return; + } if (!this._performingAction) consumeEvent(event); this._activeModel = this._hoveredModel; @@ -217,6 +261,10 @@ export class Recorder { return; if (this._shouldIgnoreMouseEvent(event)) return; + if (this._mode === 'recording' && this._tool === 'assert') { + consumeEvent(event); + return; + } if (!this._performingAction) consumeEvent(event); } @@ -226,6 +274,18 @@ export class Recorder { return; if (this._mode === 'none') return; + if (this._mode === 'recording' && this._tool === 'assert') { + if (!event.buttons) + return; + const pos = this._selectionPosition(event); + if (pos && this._selectionModel) { + this._selectionModel.focus = pos; + this._syncDocumentSelection(); + this._updateSelectionHighlight(); + } + consumeEvent(event); + return; + } const target = this._deepEventTarget(event); if (this._hoveredElement === target) return; @@ -246,6 +306,8 @@ export class Recorder { private _onFocus(userGesture: boolean) { if (this._mode === 'none') return; + if (this._mode === 'recording' && this._tool === 'assert') + return; const activeElement = this._deepActiveElement(this.document); // Firefox dispatches "focus" event to body when clicking on a backgrounded headed browser window. // We'd like to ignore this stray event. @@ -273,10 +335,49 @@ export class Recorder { this._updateHighlight(true); } + private _getSelectionText() { + this._syncDocumentSelection(); + // TODO: use elementText() passing |range=selection.getRangeAt(0)| for proper text. + return normalizeWhiteSpace(this.document.getSelection()?.toString() || ''); + } + + private _syncDocumentSelection() { + if (!this._selectionModel) { + this.document.getSelection()?.empty(); + return; + } + this.document.getSelection()?.setBaseAndExtent( + this._selectionModel.anchor.node, + this._selectionModel.anchor.offset, + this._selectionModel.focus.node, + this._selectionModel.focus.offset, + ); + } + + private _updateSelectionHighlight() { + if (!this._selectionModel) + return; + const focusElement = enclosingElement(this._selectionModel.focus.node); + let lcaElement = focusElement ? enclosingElement(this._selectionModel.anchor.node) : undefined; + while (lcaElement && !isInsideScope(lcaElement, focusElement)) + lcaElement = parentElementOrShadowHost(lcaElement); + const highlight = lcaElement ? generateSelector(this._injectedScript, lcaElement, { testIdAttributeName: this._testIdAttributeName, forTextExpect: true }) : undefined; + if (highlight?.selector === this._selectionModel.highlight?.selector) + return; + this._selectionModel.highlight = highlight; + this._updateHighlight(false); + } + private _updateHighlight(userGesture: boolean) { - const elements = this._hoveredModel ? this._hoveredModel.elements : []; - const selector = this._hoveredModel ? this._hoveredModel.selector : ''; - this._highlight.updateHighlight(elements, selector, this._mode === 'recording'); + const model = this._selectionModel?.highlight ?? this._hoveredModel; + const elements = model?.elements ?? []; + const selector = model?.selector ?? ''; + let color: string | undefined; + if (model === this._selectionModel?.highlight) + color = '#6fdcbd38'; + else if (this._mode === 'recording') + color = '#dc6f6f7f'; + this._highlight.updateHighlight(elements, selector, color); if (userGesture) this._delegate.highlightUpdated?.(); } @@ -363,6 +464,29 @@ export class Recorder { } if (this._mode !== 'recording') return; + if (this._mode === 'recording' && this._tool === 'assert') { + if (event.key === 'Escape') { + this._selectionModel = undefined; + this._syncDocumentSelection(); + this._updateHighlight(false); + } else if (event.key === 'Enter') { + if (this._selectionModel?.highlight) { + const text = this._getSelectionText(); + this._delegate.recordAction?.({ + name: 'assertText', + selector: this._selectionModel.highlight.selector, + signals: [], + text, + substring: normalizeWhiteSpace(elementText(this._injectedScript._evaluator._cacheText, this._selectionModel.highlight.elements[0]).full) !== text, + }); + this._selectionModel = undefined; + this._syncDocumentSelection(); + this._updateHighlight(false); + } + } + consumeEvent(event); + return; + } if (!this._shouldGenerateKeyPressFor(event)) return; if (this._actionInProgress(event)) { @@ -474,6 +598,12 @@ type HighlightModel = { elements: Element[]; }; +type SelectionModel = { + anchor: { node: Node, offset: number }; + focus: { node: Node, offset: number }; + highlight?: HighlightModel; +}; + function asCheckbox(node: Node | null): HTMLInputElement | null { if (!node || node.nodeName !== 'INPUT') return null; diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 1af23a63ed9e5..7aa8d1d664ce1 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -60,18 +60,36 @@ const kCSSTagNameScore = 530; const kNthScore = 10000; const kCSSFallbackScore = 10000000; +const kScoreThresholdForTextExpect = 1000; + export type GenerateSelectorOptions = { testIdAttributeName: string; omitInternalEngines?: boolean; root?: Element | Document; + forTextExpect?: boolean; }; export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } { injectedScript._evaluator.begin(); beginAriaCaches(); try { - targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement; - const targetTokens = generateSelectorFor(injectedScript, targetElement, options); + let targetTokens: SelectorToken[]; + if (options.forTextExpect) { + targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options); + for (let element: Element | undefined = targetElement; element; element = parentElementOrShadowHost(element)) { + const tokens = generateSelectorFor(injectedScript, element, options); + if (!tokens) + continue; + const score = combineScores(tokens); + if (score <= kScoreThresholdForTextExpect) { + targetTokens = tokens; + break; + } + } + } else { + targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement; + targetTokens = generateSelectorFor(injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options); + } const selector = joinTokens(targetTokens); const parsedSelector = injectedScript.parseSelector(selector); return { @@ -91,7 +109,7 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][] return textCandidates.filter(c => c[0].selector[0] !== '/'); } -function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] { +function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] | null { if (options.root && !isInsideScope(options.root, targetElement)) throw new Error(`Target element must belong to the root's subtree`); @@ -170,7 +188,7 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem return value; }; - return calculateCached(targetElement, true) || cssFallback(injectedScript, targetElement, options); + return calculate(targetElement, !options.forTextExpect); } function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, options: GenerateSelectorOptions): SelectorToken[] { @@ -227,19 +245,9 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, if (ariaRole && !['none', 'presentation'].includes(ariaRole)) candidates.push({ engine: 'internal:role', selector: ariaRole, score: kRoleWithoutNameScore }); - if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) { - candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, false)}]`, score: kAltTextScore }); - candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, true)}]`, score: kAltTextScoreExact }); - } - if (element.getAttribute('name') && ['BUTTON', 'FORM', 'FIELDSET', 'FRAME', 'IFRAME', 'INPUT', 'KEYGEN', 'OBJECT', 'OUTPUT', 'SELECT', 'TEXTAREA', 'MAP', 'META', 'PARAM'].includes(element.nodeName)) candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[name=${quoteAttributeValue(element.getAttribute('name')!)}]`, score: kCSSInputTypeNameScore }); - if (element.getAttribute('title')) { - candidates.push({ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(element.getAttribute('title')!, false)}]`, score: kTitleScore }); - candidates.push({ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(element.getAttribute('title')!, true)}]`, score: kTitleScoreExact }); - } - if (['INPUT', 'TEXTAREA'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden') { if (element.getAttribute('type')) candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[type=${quoteAttributeValue(element.getAttribute('type')!)}]`, score: kCSSInputTypeNameScore }); @@ -257,6 +265,16 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i return []; const candidates: SelectorToken[][] = []; + if (element.getAttribute('title')) { + candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(element.getAttribute('title')!, false)}]`, score: kTitleScore }]); + candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(element.getAttribute('title')!, true)}]`, score: kTitleScoreExact }]); + } + + if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) { + candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, false)}]`, score: kAltTextScore }]); + candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, true)}]`, score: kAltTextScoreExact }]); + } + const fullText = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full); const text = fullText.substring(0, 80); if (text) { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 6930610f388f6..2a4d531c20dba 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -35,7 +35,7 @@ import type { IRecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import type { Point } from '../common/types'; -import type { CallLog, CallLogStatus, EventData, Mode, Source, UIState } from '@recorder/recorderTypes'; +import type { CallLog, CallLogStatus, EventData, Mode, RecordingTool, Source, UIState } from '@recorder/recorderTypes'; import { createGuid, isUnderTest, monotonicTime } from '../utils'; import { metadataToCallLog } from './recorder/recorderUtils'; import { Debugger } from './debugger'; @@ -53,6 +53,7 @@ const recorderSymbol = Symbol('recorderSymbol'); export class Recorder implements InstrumentationListener { private _context: BrowserContext; private _mode: Mode; + private _tool: RecordingTool = 'action'; private _highlightedSelector = ''; private _recorderApp: IRecorderApp | null = null; private _currentCallsMetadata = new Map(); @@ -116,6 +117,10 @@ export class Recorder implements InstrumentationListener { this.setMode(data.params.mode); return; } + if (data.event === 'setRecordingTool') { + this.setRecordingTool(data.params.tool); + return; + } if (data.event === 'selectorUpdated') { this.setHighlightedSelector(this._currentLanguage, data.params.selector); return; @@ -175,6 +180,7 @@ export class Recorder implements InstrumentationListener { } const uiState: UIState = { mode: this._mode, + tool: this._tool, actionPoint, actionSelector, language: this._currentLanguage, @@ -233,6 +239,14 @@ export class Recorder implements InstrumentationListener { this._refreshOverlay(); } + setRecordingTool(tool: RecordingTool) { + if (this._tool === tool) + return; + this._tool = tool; + this._recorderApp?.setRecordingTool(this._tool); + this._refreshOverlay(); + } + resume() { this._debugger.resume(false); } diff --git a/packages/playwright-core/src/server/recorder/csharp.ts b/packages/playwright-core/src/server/recorder/csharp.ts index 498d6d81fd6e4..5488445f1c530 100644 --- a/packages/playwright-core/src/server/recorder/csharp.ts +++ b/packages/playwright-core/src/server/recorder/csharp.ts @@ -98,8 +98,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator { } const lines: string[] = []; - const actionCall = this._generateActionCall(action, actionInContext.frame.isMainFrame); - lines.push(`await ${subject}.${actionCall};`); + lines.push(this._generateActionCall(subject, action)); if (signals.download) { lines.unshift(`var download${signals.download.downloadAlias} = await ${pageAlias}.RunAndWaitForDownloadAsync(async () =>\n{`); @@ -117,12 +116,12 @@ export class CSharpLanguageGenerator implements LanguageGenerator { return formatter.format(); } - private _generateActionCall(action: Action, isPage: boolean): string { + private _generateActionCall(subject: string, action: Action): string { switch (action.name) { case 'openPage': throw Error('Not reached'); case 'closePage': - return 'CloseAsync()'; + return `await ${subject}.CloseAsync();`; case 'click': { let method = 'Click'; if (action.clickCount === 2) @@ -138,27 +137,29 @@ export class CSharpLanguageGenerator implements LanguageGenerator { if (action.position) options.position = action.position; if (!Object.entries(options).length) - return this._asLocator(action.selector) + `.${method}Async()`; + return `await ${subject}.${this._asLocator(action.selector)}.${method}Async();`; const optionsString = formatObject(options, ' ', 'Locator' + method + 'Options'); - return this._asLocator(action.selector) + `.${method}Async(${optionsString})`; + return `await ${subject}.${this._asLocator(action.selector)}.${method}Async(${optionsString});`; } case 'check': - return this._asLocator(action.selector) + `.CheckAsync()`; + return `await ${subject}.${this._asLocator(action.selector)}.CheckAsync();`; case 'uncheck': - return this._asLocator(action.selector) + `.UncheckAsync()`; + return `await ${subject}.${this._asLocator(action.selector)}.UncheckAsync();`; case 'fill': - return this._asLocator(action.selector) + `.FillAsync(${quote(action.text)})`; + return `await ${subject}.${this._asLocator(action.selector)}.FillAsync(${quote(action.text)});`; case 'setInputFiles': - return this._asLocator(action.selector) + `.SetInputFilesAsync(${formatObject(action.files)})`; + return `await ${subject}.${this._asLocator(action.selector)}.SetInputFilesAsync(${formatObject(action.files)});`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return this._asLocator(action.selector) + `.PressAsync(${quote(shortcut)})`; + return `await ${subject}.${this._asLocator(action.selector)}.PressAsync(${quote(shortcut)});`; } case 'navigate': - return `GotoAsync(${quote(action.url)})`; + return `await ${subject}.GotoAsync(${quote(action.url)});`; case 'select': - return this._asLocator(action.selector) + `.SelectOptionAsync(${formatObject(action.options)})`; + return `await ${subject}.${this._asLocator(action.selector)}.SelectOptionAsync(${formatObject(action.options)});`; + case 'assertText': + return `await Expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'ToContainTextAsync' : 'ToHaveTextAsync'}(${quote(action.text)});`; } } diff --git a/packages/playwright-core/src/server/recorder/java.ts b/packages/playwright-core/src/server/recorder/java.ts index 1c653740df065..63e772ca9515a 100644 --- a/packages/playwright-core/src/server/recorder/java.ts +++ b/packages/playwright-core/src/server/recorder/java.ts @@ -67,8 +67,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { });`); } - const actionCall = this._generateActionCall(action, inFrameLocator); - let code = `${subject}.${actionCall};`; + let code = this._generateActionCall(subject, action, inFrameLocator); if (signals.popup) { code = `Page ${signals.popup.popupAlias} = ${pageAlias}.waitForPopup(() -> { @@ -87,12 +86,12 @@ export class JavaLanguageGenerator implements LanguageGenerator { return formatter.format(); } - private _generateActionCall(action: Action, inFrameLocator: boolean): string { + private _generateActionCall(subject: string, action: Action, inFrameLocator: boolean): string { switch (action.name) { case 'openPage': throw Error('Not reached'); case 'closePage': - return 'close()'; + return `${subject}.close();`; case 'click': { let method = 'click'; if (action.clickCount === 2) @@ -108,25 +107,27 @@ export class JavaLanguageGenerator implements LanguageGenerator { if (action.position) options.position = action.position; const optionsText = formatClickOptions(options); - return this._asLocator(action.selector, inFrameLocator) + `.${method}(${optionsText})`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`; } case 'check': - return this._asLocator(action.selector, inFrameLocator) + `.check()`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.check();`; case 'uncheck': - return this._asLocator(action.selector, inFrameLocator) + `.uncheck()`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.uncheck();`; case 'fill': - return this._asLocator(action.selector, inFrameLocator) + `.fill(${quote(action.text)})`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.fill(${quote(action.text)});`; case 'setInputFiles': - return this._asLocator(action.selector, inFrameLocator) + `.setInputFiles(${formatPath(action.files.length === 1 ? action.files[0] : action.files)})`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.setInputFiles(${formatPath(action.files.length === 1 ? action.files[0] : action.files)});`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return this._asLocator(action.selector, inFrameLocator) + `.press(${quote(shortcut)})`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.press(${quote(shortcut)});`; } case 'navigate': - return `navigate(${quote(action.url)})`; + return `${subject}.navigate(${quote(action.url)});`; case 'select': - return this._asLocator(action.selector, inFrameLocator) + `.selectOption(${formatSelectOption(action.options.length > 1 ? action.options : action.options[0])})`; + return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.selectOption(${formatSelectOption(action.options.length > 1 ? action.options : action.options[0])});`; + case 'assertText': + return `assertThat(${subject}.${this._asLocator(action.selector, inFrameLocator)}).${action.substring ? 'containsText' : 'hasText'}(${quote(action.text)});`; } } diff --git a/packages/playwright-core/src/server/recorder/javascript.ts b/packages/playwright-core/src/server/recorder/javascript.ts index 6d1a27f5c141a..c462b3aa6e540 100644 --- a/packages/playwright-core/src/server/recorder/javascript.ts +++ b/packages/playwright-core/src/server/recorder/javascript.ts @@ -79,8 +79,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { if (signals.download) formatter.add(`const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`); - const actionCall = this._generateActionCall(action); - formatter.add(`await ${subject}.${actionCall};`); + formatter.add(this._generateActionCall(subject, action)); if (signals.popup) formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`); @@ -90,12 +89,12 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { return formatter.format(); } - private _generateActionCall(action: Action): string { + private _generateActionCall(subject: string, action: Action): string { switch (action.name) { case 'openPage': throw Error('Not reached'); case 'closePage': - return 'close()'; + return `await ${subject}.close();`; case 'click': { let method = 'click'; if (action.clickCount === 2) @@ -111,25 +110,27 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { if (action.position) options.position = action.position; const optionsString = formatOptions(options, false); - return this._asLocator(action.selector) + `.${method}(${optionsString})`; + return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`; } case 'check': - return this._asLocator(action.selector) + `.check()`; + return `await ${subject}.${this._asLocator(action.selector)}.check();`; case 'uncheck': - return this._asLocator(action.selector) + `.uncheck()`; + return `await ${subject}.${this._asLocator(action.selector)}.uncheck();`; case 'fill': - return this._asLocator(action.selector) + `.fill(${quote(action.text)})`; + return `await ${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)});`; case 'setInputFiles': - return this._asLocator(action.selector) + `.setInputFiles(${formatObject(action.files.length === 1 ? action.files[0] : action.files)})`; + return `await ${subject}.${this._asLocator(action.selector)}.setInputFiles(${formatObject(action.files.length === 1 ? action.files[0] : action.files)});`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return this._asLocator(action.selector) + `.press(${quote(shortcut)})`; + return `await ${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)});`; } case 'navigate': - return `goto(${quote(action.url)})`; + return `await ${subject}.goto(${quote(action.url)});`; case 'select': - return this._asLocator(action.selector) + `.selectOption(${formatObject(action.options.length > 1 ? action.options : action.options[0])})`; + return `await ${subject}.${this._asLocator(action.selector)}.selectOption(${formatObject(action.options.length > 1 ? action.options : action.options[0])});`; + case 'assertText': + return `await expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'toContainText' : 'toHaveText'}(${quote(action.text)});`; } } diff --git a/packages/playwright-core/src/server/recorder/python.ts b/packages/playwright-core/src/server/recorder/python.ts index e67f94491f68a..18245e21b89b9 100644 --- a/packages/playwright-core/src/server/recorder/python.ts +++ b/packages/playwright-core/src/server/recorder/python.ts @@ -77,8 +77,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { if (signals.dialog) formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`); - const actionCall = this._generateActionCall(action); - let code = `${this._awaitPrefix}${subject}.${actionCall}`; + let code = `${this._awaitPrefix}${this._generateActionCall(subject, action)}`; if (signals.popup) { code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as ${signals.popup.popupAlias}_info { @@ -99,12 +98,12 @@ export class PythonLanguageGenerator implements LanguageGenerator { return formatter.format(); } - private _generateActionCall(action: Action): string { + private _generateActionCall(subject: string, action: Action): string { switch (action.name) { case 'openPage': throw Error('Not reached'); case 'closePage': - return 'close()'; + return `${subject}.close()`; case 'click': { let method = 'click'; if (action.clickCount === 2) @@ -120,25 +119,27 @@ export class PythonLanguageGenerator implements LanguageGenerator { if (action.position) options.position = action.position; const optionsString = formatOptions(options, false); - return this._asLocator(action.selector) + `.${method}(${optionsString})`; + return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`; } case 'check': - return this._asLocator(action.selector) + `.check()`; + return `${subject}.${this._asLocator(action.selector)}.check()`; case 'uncheck': - return this._asLocator(action.selector) + `.uncheck()`; + return `${subject}.${this._asLocator(action.selector)}.uncheck()`; case 'fill': - return this._asLocator(action.selector) + `.fill(${quote(action.text)})`; + return `${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`; case 'setInputFiles': - return this._asLocator(action.selector) + `.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; + return `${subject}.${this._asLocator(action.selector)}.set_input_files(${formatValue(action.files.length === 1 ? action.files[0] : action.files)})`; case 'press': { const modifiers = toModifiers(action.modifiers); const shortcut = [...modifiers, action.key].join('+'); - return this._asLocator(action.selector) + `.press(${quote(shortcut)})`; + return `${subject}.${this._asLocator(action.selector)}.press(${quote(shortcut)})`; } case 'navigate': - return `goto(${quote(action.url)})`; + return `${subject}.goto(${quote(action.url)})`; case 'select': - return this._asLocator(action.selector) + `.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; + return `${subject}.${this._asLocator(action.selector)}.select_option(${formatValue(action.options.length === 1 ? action.options[0] : action.options)})`; + case 'assertText': + return `expect(${subject}.${this._asLocator(action.selector)}).${action.substring ? 'to_contain_text' : 'to_have_text'}(${quote(action.text)})`; } } diff --git a/packages/playwright-core/src/server/recorder/recorderActions.ts b/packages/playwright-core/src/server/recorder/recorderActions.ts index f51ab3de67e08..a0c8b2dc32ea2 100644 --- a/packages/playwright-core/src/server/recorder/recorderActions.ts +++ b/packages/playwright-core/src/server/recorder/recorderActions.ts @@ -26,7 +26,8 @@ export type ActionName = 'press' | 'select' | 'uncheck' | - 'setInputFiles'; + 'setInputFiles' | + 'assertText'; export type ActionBase = { name: ActionName, @@ -91,7 +92,14 @@ export type SetInputFilesAction = ActionBase & { files: string[], }; -export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction; +export type AssertTextAction = ActionBase & { + name: 'assertText', + selector: string, + text: string, + substring: boolean, +}; + +export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction; // Signals. diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 0ec712da64dfb..97bca8e9c677c 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -20,7 +20,7 @@ import type { Page } from '../page'; import { ProgressController } from '../progress'; import { EventEmitter } from 'events'; import { serverSideCallMetadata } from '../instrumentation'; -import type { CallLog, EventData, Mode, Source } from '@recorder/recorderTypes'; +import type { CallLog, EventData, Mode, RecordingTool, Source } from '@recorder/recorderTypes'; import { isUnderTest } from '../../utils'; import { mime } from '../../utilsBundle'; import { syncLocalStorageWithSettings } from '../launchApp'; @@ -44,7 +44,8 @@ declare global { export interface IRecorderApp extends EventEmitter { close(): Promise; setPaused(paused: boolean): Promise; - setMode(mode: 'none' | 'recording' | 'inspecting'): Promise; + setMode(mode: Mode): Promise; + setRecordingTool(tool: RecordingTool): Promise; setFileIfNeeded(file: string): Promise; setSelector(selector: string, focus?: boolean): Promise; updateCallLogs(callLogs: CallLog[]): Promise; @@ -54,7 +55,8 @@ export interface IRecorderApp extends EventEmitter { export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { async close(): Promise {} async setPaused(paused: boolean): Promise {} - async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise {} + async setMode(mode: Mode): Promise {} + async setRecordingTool(tool: RecordingTool): Promise {} async setFileIfNeeded(file: string): Promise {} async setSelector(selector: string, focus?: boolean): Promise {} async updateCallLogs(callLogs: CallLog[]): Promise {} @@ -138,12 +140,18 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { return result; } - async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise { + async setMode(mode: Mode): Promise { await this._page.mainFrame().evaluateExpression(((mode: Mode) => { window.playwrightSetMode(mode); }).toString(), { isFunction: true }, mode).catch(() => {}); } + async setRecordingTool(tool: RecordingTool): Promise { + await this._page.mainFrame().evaluateExpression(((tool: RecordingTool) => { + window.playwrightSetRecordingTool(tool); + }).toString(), { isFunction: true }, tool).catch(() => {}); + } + async setFileIfNeeded(file: string): Promise { await this._page.mainFrame().evaluateExpression(((file: string) => { window.playwrightSetFileIfNeeded(file); diff --git a/packages/recorder/src/main.tsx b/packages/recorder/src/main.tsx index ddd9d90acd189..8be0d0d2d2d1b 100644 --- a/packages/recorder/src/main.tsx +++ b/packages/recorder/src/main.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import type { CallLog, Mode, Source } from './recorderTypes'; +import type { CallLog, Mode, RecordingTool, Source } from './recorderTypes'; import * as React from 'react'; import { Recorder } from './recorder'; import './recorder.css'; @@ -25,8 +25,10 @@ export const Main: React.FC = ({ const [paused, setPaused] = React.useState(false); const [log, setLog] = React.useState(new Map()); const [mode, setMode] = React.useState('none'); + const [tool, setTool] = React.useState('action'); window.playwrightSetMode = setMode; + window.playwrightSetRecordingTool = setTool; window.playwrightSetSources = setSources; window.playwrightSetPaused = setPaused; window.playwrightUpdateLogs = callLogs => { @@ -39,5 +41,5 @@ export const Main: React.FC = ({ }; window.playwrightSourcesEchoForTest = sources; - return ; + return ; }; diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 7d269d03671f1..8b132f2071f77 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import type { CallLog, Mode, Source } from './recorderTypes'; +import type { CallLog, Mode, RecordingTool, Source } from './recorderTypes'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { SplitView } from '@web/components/splitView'; import { TabbedPane } from '@web/components/tabbedPane'; @@ -40,6 +40,7 @@ export interface RecorderProps { paused: boolean, log: Map, mode: Mode, + tool: RecordingTool, } export const Recorder: React.FC = ({ @@ -47,6 +48,7 @@ export const Recorder: React.FC = ({ paused, log, mode, + tool, }) => { const [fileId, setFileId] = React.useState(); const [selectedTab, setSelectedTab] = React.useState('log'); @@ -116,6 +118,9 @@ export const Recorder: React.FC = ({ { window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' } }); }}>Record + { + window.dispatch({ event: 'setRecordingTool', params: { tool: tool === 'assert' ? 'action' : 'assert' } }); + }}>Assert { copy(source.text); }}> diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index 4820a8abee73b..62f174729df2d 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -20,13 +20,16 @@ export type Point = { x: number, y: number }; export type Mode = 'inspecting' | 'recording' | 'none'; +export type RecordingTool = 'action' | 'assert'; + export type EventData = { - event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'fileChanged'; + event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'setRecordingTool' | 'selectorUpdated' | 'fileChanged'; params: any; }; export type UIState = { mode: Mode; + tool: RecordingTool; actionPoint?: Point; actionSelector?: string; language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; @@ -72,6 +75,7 @@ export type Source = { declare global { interface Window { playwrightSetMode: (mode: Mode) => void; + playwrightSetRecordingTool: (tool: RecordingTool) => void; playwrightSetPaused: (paused: boolean) => void; playwrightSetSources: (sources: Source[]) => void; playwrightUpdateLogs: (callLogs: CallLog[]) => void; diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 39ed75842100e..ca55c2c94190e 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -239,6 +239,7 @@ export const InspectModeController: React.FunctionComponent<{ const actionSelector = locatorOrSelectorAsSelector(sdkLanguage, highlightedLocator, testIdAttributeName); recorder.setUIState({ mode: isInspecting ? 'inspecting' : 'none', + tool: 'action', actionSelector: actionSelector.startsWith(frameSelector) ? actionSelector.substring(frameSelector.length).trim() : undefined, language: sdkLanguage, testIdAttributeName,