feat(expect): generate toHaveText (#27824)

This commit is contained in:
Dmitry Gozman 2023-10-26 18:49:14 -07:00 committed by GitHub
parent 54ebee79f7
commit 24deac458b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 284 additions and 87 deletions

View file

@ -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;

View file

@ -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) : '' });
}

View file

@ -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<void>;
@ -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;

View file

@ -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) {

View file

@ -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<CallMetadata, SdkObject>();
@ -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);
}

View file

@ -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)});`;
}
}

View file

@ -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)});`;
}
}

View file

@ -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)});`;
}
}

View file

@ -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)})`;
}
}

View file

@ -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.

View file

@ -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<void>;
setPaused(paused: boolean): Promise<void>;
setMode(mode: 'none' | 'recording' | 'inspecting'): Promise<void>;
setMode(mode: Mode): Promise<void>;
setRecordingTool(tool: RecordingTool): Promise<void>;
setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, focus?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>;
@ -54,7 +55,8 @@ export interface IRecorderApp extends EventEmitter {
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise<void> {}
async setMode(mode: Mode): Promise<void> {}
async setRecordingTool(tool: RecordingTool): Promise<void> {}
async setFileIfNeeded(file: string): Promise<void> {}
async setSelector(selector: string, focus?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
@ -138,12 +140,18 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
return result;
}
async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise<void> {
async setMode(mode: Mode): Promise<void> {
await this._page.mainFrame().evaluateExpression(((mode: Mode) => {
window.playwrightSetMode(mode);
}).toString(), { isFunction: true }, mode).catch(() => {});
}
async setRecordingTool(tool: RecordingTool): Promise<void> {
await this._page.mainFrame().evaluateExpression(((tool: RecordingTool) => {
window.playwrightSetRecordingTool(tool);
}).toString(), { isFunction: true }, tool).catch(() => {});
}
async setFileIfNeeded(file: string): Promise<void> {
await this._page.mainFrame().evaluateExpression(((file: string) => {
window.playwrightSetFileIfNeeded(file);

View file

@ -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<string, CallLog>());
const [mode, setMode] = React.useState<Mode>('none');
const [tool, setTool] = React.useState<RecordingTool>('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 <Recorder sources={sources} paused={paused} log={log} mode={mode}/>;
return <Recorder sources={sources} paused={paused} log={log} mode={mode} tool={tool}/>;
};

View file

@ -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<string, CallLog>,
mode: Mode,
tool: RecordingTool,
}
export const Recorder: React.FC<RecorderProps> = ({
@ -47,6 +48,7 @@ export const Recorder: React.FC<RecorderProps> = ({
paused,
log,
mode,
tool,
}) => {
const [fileId, setFileId] = React.useState<string | undefined>();
const [selectedTab, setSelectedTab] = React.useState<string>('log');
@ -116,6 +118,9 @@ export const Recorder: React.FC<RecorderProps> = ({
<ToolbarButton icon='record' title='Record' toggled={mode === 'recording'} onClick={() => {
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' } });
}}>Record</ToolbarButton>
<ToolbarButton icon='check-all' title={tool === 'action' ? 'Recording actions' : 'Recording assertions'} toggled={tool === 'assert'} disabled={mode !== 'recording'} onClick={() => {
window.dispatch({ event: 'setRecordingTool', params: { tool: tool === 'assert' ? 'action' : 'assert' } });
}}>Assert</ToolbarButton>
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
copy(source.text);
}}></ToolbarButton>

View file

@ -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;

View file

@ -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,