feat(expect): generate toHaveText (#27824)
This commit is contained in:
parent
54ebee79f7
commit
24deac458b
|
|
@ -23,6 +23,12 @@ export function isInsideScope(scope: Node, element: Element | undefined): boolea
|
||||||
return false;
|
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 {
|
export function parentElementOrShadowHost(element: Element): Element | undefined {
|
||||||
if (element.parentElement)
|
if (element.parentElement)
|
||||||
return element.parentElement;
|
return element.parentElement;
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export class Highlight {
|
||||||
runHighlightOnRaf(selector: ParsedSelector) {
|
runHighlightOnRaf(selector: ParsedSelector) {
|
||||||
if (this._rafRequest)
|
if (this._rafRequest)
|
||||||
cancelAnimationFrame(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));
|
this._rafRequest = requestAnimationFrame(() => this.runHighlightOnRaf(selector));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,11 +144,8 @@ export class Highlight {
|
||||||
this._highlightEntries = [];
|
this._highlightEntries = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHighlight(elements: Element[], selector: string, isRecording: boolean) {
|
updateHighlight(elements: Element[], selector: string, color?: string) {
|
||||||
let color: string;
|
if (!color)
|
||||||
if (isRecording)
|
|
||||||
color = '#dc6f6f7f';
|
|
||||||
else
|
|
||||||
color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f';
|
color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f';
|
||||||
this._innerUpdateHighlight(elements, { color, tooltipText: selector ? asLocator(this._language, selector) : '' });
|
this._innerUpdateHighlight(elements, { color, tooltipText: selector ? asLocator(this._language, selector) : '' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,11 @@ import type * as actions from '../recorder/recorderActions';
|
||||||
import type { InjectedScript } from '../injected/injectedScript';
|
import type { InjectedScript } from '../injected/injectedScript';
|
||||||
import { generateSelector } from '../injected/selectorGenerator';
|
import { generateSelector } from '../injected/selectorGenerator';
|
||||||
import type { Point } from '../../common/types';
|
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 { Highlight } from '../injected/highlight';
|
||||||
|
import { enclosingElement, isInsideScope, parentElementOrShadowHost } from './domUtils';
|
||||||
|
import { elementText } from './selectorUtils';
|
||||||
|
import { normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
||||||
|
|
||||||
interface RecorderDelegate {
|
interface RecorderDelegate {
|
||||||
performAction?(action: actions.Action): Promise<void>;
|
performAction?(action: actions.Action): Promise<void>;
|
||||||
|
|
@ -36,7 +39,9 @@ export class Recorder {
|
||||||
private _hoveredElement: HTMLElement | null = null;
|
private _hoveredElement: HTMLElement | null = null;
|
||||||
private _activeModel: HighlightModel | null = null;
|
private _activeModel: HighlightModel | null = null;
|
||||||
private _expectProgrammaticKeyUp = false;
|
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 _actionPoint: Point | undefined;
|
||||||
private _actionSelector: string | undefined;
|
private _actionSelector: string | undefined;
|
||||||
private _highlight: Highlight;
|
private _highlight: Highlight;
|
||||||
|
|
@ -93,11 +98,12 @@ export class Recorder {
|
||||||
else
|
else
|
||||||
this.uninstallListeners();
|
this.uninstallListeners();
|
||||||
|
|
||||||
const { mode, actionPoint, actionSelector, language, testIdAttributeName } = state;
|
const { mode, tool, actionPoint, actionSelector, language, testIdAttributeName } = state;
|
||||||
this._testIdAttributeName = testIdAttributeName;
|
this._testIdAttributeName = testIdAttributeName;
|
||||||
this._highlight.setLanguage(language);
|
this._highlight.setLanguage(language);
|
||||||
if (mode !== this._mode) {
|
if (mode !== this._mode || this._tool !== tool) {
|
||||||
this._mode = mode;
|
this._mode = mode;
|
||||||
|
this._tool = tool;
|
||||||
this.clearHighlight();
|
this.clearHighlight();
|
||||||
}
|
}
|
||||||
if (actionPoint && this._actionPoint && actionPoint.x === this._actionPoint.x && actionPoint.y === this._actionPoint.y) {
|
if (actionPoint && this._actionPoint && actionPoint.x === this._actionPoint.x && actionPoint.y === this._actionPoint.y) {
|
||||||
|
|
@ -126,6 +132,10 @@ export class Recorder {
|
||||||
clearHighlight() {
|
clearHighlight() {
|
||||||
this._hoveredModel = null;
|
this._hoveredModel = null;
|
||||||
this._activeModel = null;
|
this._activeModel = null;
|
||||||
|
if (this._selectionModel) {
|
||||||
|
this._selectionModel = undefined;
|
||||||
|
this._syncDocumentSelection();
|
||||||
|
}
|
||||||
this._updateHighlight(false);
|
this._updateHighlight(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +167,19 @@ export class Recorder {
|
||||||
return;
|
return;
|
||||||
if (this._mode === 'inspecting')
|
if (this._mode === 'inspecting')
|
||||||
this._delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : '');
|
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))
|
if (this._shouldIgnoreMouseEvent(event))
|
||||||
return;
|
return;
|
||||||
if (this._actionInProgress(event))
|
if (this._actionInProgress(event))
|
||||||
|
|
@ -202,11 +225,32 @@ export class Recorder {
|
||||||
return false;
|
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) {
|
private _onMouseDown(event: MouseEvent) {
|
||||||
if (!event.isTrusted)
|
if (!event.isTrusted)
|
||||||
return;
|
return;
|
||||||
if (this._shouldIgnoreMouseEvent(event))
|
if (this._shouldIgnoreMouseEvent(event))
|
||||||
return;
|
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)
|
if (!this._performingAction)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
this._activeModel = this._hoveredModel;
|
this._activeModel = this._hoveredModel;
|
||||||
|
|
@ -217,6 +261,10 @@ export class Recorder {
|
||||||
return;
|
return;
|
||||||
if (this._shouldIgnoreMouseEvent(event))
|
if (this._shouldIgnoreMouseEvent(event))
|
||||||
return;
|
return;
|
||||||
|
if (this._mode === 'recording' && this._tool === 'assert') {
|
||||||
|
consumeEvent(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this._performingAction)
|
if (!this._performingAction)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +274,18 @@ export class Recorder {
|
||||||
return;
|
return;
|
||||||
if (this._mode === 'none')
|
if (this._mode === 'none')
|
||||||
return;
|
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);
|
const target = this._deepEventTarget(event);
|
||||||
if (this._hoveredElement === target)
|
if (this._hoveredElement === target)
|
||||||
return;
|
return;
|
||||||
|
|
@ -246,6 +306,8 @@ export class Recorder {
|
||||||
private _onFocus(userGesture: boolean) {
|
private _onFocus(userGesture: boolean) {
|
||||||
if (this._mode === 'none')
|
if (this._mode === 'none')
|
||||||
return;
|
return;
|
||||||
|
if (this._mode === 'recording' && this._tool === 'assert')
|
||||||
|
return;
|
||||||
const activeElement = this._deepActiveElement(this.document);
|
const activeElement = this._deepActiveElement(this.document);
|
||||||
// Firefox dispatches "focus" event to body when clicking on a backgrounded headed browser window.
|
// Firefox dispatches "focus" event to body when clicking on a backgrounded headed browser window.
|
||||||
// We'd like to ignore this stray event.
|
// We'd like to ignore this stray event.
|
||||||
|
|
@ -273,10 +335,49 @@ export class Recorder {
|
||||||
this._updateHighlight(true);
|
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) {
|
private _updateHighlight(userGesture: boolean) {
|
||||||
const elements = this._hoveredModel ? this._hoveredModel.elements : [];
|
const model = this._selectionModel?.highlight ?? this._hoveredModel;
|
||||||
const selector = this._hoveredModel ? this._hoveredModel.selector : '';
|
const elements = model?.elements ?? [];
|
||||||
this._highlight.updateHighlight(elements, selector, this._mode === 'recording');
|
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)
|
if (userGesture)
|
||||||
this._delegate.highlightUpdated?.();
|
this._delegate.highlightUpdated?.();
|
||||||
}
|
}
|
||||||
|
|
@ -363,6 +464,29 @@ export class Recorder {
|
||||||
}
|
}
|
||||||
if (this._mode !== 'recording')
|
if (this._mode !== 'recording')
|
||||||
return;
|
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))
|
if (!this._shouldGenerateKeyPressFor(event))
|
||||||
return;
|
return;
|
||||||
if (this._actionInProgress(event)) {
|
if (this._actionInProgress(event)) {
|
||||||
|
|
@ -474,6 +598,12 @@ type HighlightModel = {
|
||||||
elements: Element[];
|
elements: Element[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SelectionModel = {
|
||||||
|
anchor: { node: Node, offset: number };
|
||||||
|
focus: { node: Node, offset: number };
|
||||||
|
highlight?: HighlightModel;
|
||||||
|
};
|
||||||
|
|
||||||
function asCheckbox(node: Node | null): HTMLInputElement | null {
|
function asCheckbox(node: Node | null): HTMLInputElement | null {
|
||||||
if (!node || node.nodeName !== 'INPUT')
|
if (!node || node.nodeName !== 'INPUT')
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -60,18 +60,36 @@ const kCSSTagNameScore = 530;
|
||||||
const kNthScore = 10000;
|
const kNthScore = 10000;
|
||||||
const kCSSFallbackScore = 10000000;
|
const kCSSFallbackScore = 10000000;
|
||||||
|
|
||||||
|
const kScoreThresholdForTextExpect = 1000;
|
||||||
|
|
||||||
export type GenerateSelectorOptions = {
|
export type GenerateSelectorOptions = {
|
||||||
testIdAttributeName: string;
|
testIdAttributeName: string;
|
||||||
omitInternalEngines?: boolean;
|
omitInternalEngines?: boolean;
|
||||||
root?: Element | Document;
|
root?: Element | Document;
|
||||||
|
forTextExpect?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } {
|
export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } {
|
||||||
injectedScript._evaluator.begin();
|
injectedScript._evaluator.begin();
|
||||||
beginAriaCaches();
|
beginAriaCaches();
|
||||||
try {
|
try {
|
||||||
targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement;
|
let targetTokens: SelectorToken[];
|
||||||
const targetTokens = generateSelectorFor(injectedScript, targetElement, options);
|
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 selector = joinTokens(targetTokens);
|
||||||
const parsedSelector = injectedScript.parseSelector(selector);
|
const parsedSelector = injectedScript.parseSelector(selector);
|
||||||
return {
|
return {
|
||||||
|
|
@ -91,7 +109,7 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][]
|
||||||
return textCandidates.filter(c => c[0].selector[0] !== '/');
|
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))
|
if (options.root && !isInsideScope(options.root, targetElement))
|
||||||
throw new Error(`Target element must belong to the root's subtree`);
|
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 value;
|
||||||
};
|
};
|
||||||
|
|
||||||
return calculateCached(targetElement, true) || cssFallback(injectedScript, targetElement, options);
|
return calculate(targetElement, !options.forTextExpect);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, options: GenerateSelectorOptions): SelectorToken[] {
|
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))
|
if (ariaRole && !['none', 'presentation'].includes(ariaRole))
|
||||||
candidates.push({ engine: 'internal:role', selector: ariaRole, score: kRoleWithoutNameScore });
|
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))
|
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 });
|
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 (['INPUT', 'TEXTAREA'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden') {
|
||||||
if (element.getAttribute('type'))
|
if (element.getAttribute('type'))
|
||||||
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[type=${quoteAttributeValue(element.getAttribute('type')!)}]`, score: kCSSInputTypeNameScore });
|
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 [];
|
return [];
|
||||||
const candidates: SelectorToken[][] = [];
|
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 fullText = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full);
|
||||||
const text = fullText.substring(0, 80);
|
const text = fullText.substring(0, 80);
|
||||||
if (text) {
|
if (text) {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import type { IRecorderApp } from './recorder/recorderApp';
|
||||||
import { RecorderApp } from './recorder/recorderApp';
|
import { RecorderApp } from './recorder/recorderApp';
|
||||||
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
|
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
|
||||||
import type { Point } from '../common/types';
|
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 { createGuid, isUnderTest, monotonicTime } from '../utils';
|
||||||
import { metadataToCallLog } from './recorder/recorderUtils';
|
import { metadataToCallLog } from './recorder/recorderUtils';
|
||||||
import { Debugger } from './debugger';
|
import { Debugger } from './debugger';
|
||||||
|
|
@ -53,6 +53,7 @@ const recorderSymbol = Symbol('recorderSymbol');
|
||||||
export class Recorder implements InstrumentationListener {
|
export class Recorder implements InstrumentationListener {
|
||||||
private _context: BrowserContext;
|
private _context: BrowserContext;
|
||||||
private _mode: Mode;
|
private _mode: Mode;
|
||||||
|
private _tool: RecordingTool = 'action';
|
||||||
private _highlightedSelector = '';
|
private _highlightedSelector = '';
|
||||||
private _recorderApp: IRecorderApp | null = null;
|
private _recorderApp: IRecorderApp | null = null;
|
||||||
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
||||||
|
|
@ -116,6 +117,10 @@ export class Recorder implements InstrumentationListener {
|
||||||
this.setMode(data.params.mode);
|
this.setMode(data.params.mode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.event === 'setRecordingTool') {
|
||||||
|
this.setRecordingTool(data.params.tool);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.event === 'selectorUpdated') {
|
if (data.event === 'selectorUpdated') {
|
||||||
this.setHighlightedSelector(this._currentLanguage, data.params.selector);
|
this.setHighlightedSelector(this._currentLanguage, data.params.selector);
|
||||||
return;
|
return;
|
||||||
|
|
@ -175,6 +180,7 @@ export class Recorder implements InstrumentationListener {
|
||||||
}
|
}
|
||||||
const uiState: UIState = {
|
const uiState: UIState = {
|
||||||
mode: this._mode,
|
mode: this._mode,
|
||||||
|
tool: this._tool,
|
||||||
actionPoint,
|
actionPoint,
|
||||||
actionSelector,
|
actionSelector,
|
||||||
language: this._currentLanguage,
|
language: this._currentLanguage,
|
||||||
|
|
@ -233,6 +239,14 @@ export class Recorder implements InstrumentationListener {
|
||||||
this._refreshOverlay();
|
this._refreshOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRecordingTool(tool: RecordingTool) {
|
||||||
|
if (this._tool === tool)
|
||||||
|
return;
|
||||||
|
this._tool = tool;
|
||||||
|
this._recorderApp?.setRecordingTool(this._tool);
|
||||||
|
this._refreshOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
resume() {
|
resume() {
|
||||||
this._debugger.resume(false);
|
this._debugger.resume(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const actionCall = this._generateActionCall(action, actionInContext.frame.isMainFrame);
|
lines.push(this._generateActionCall(subject, action));
|
||||||
lines.push(`await ${subject}.${actionCall};`);
|
|
||||||
|
|
||||||
if (signals.download) {
|
if (signals.download) {
|
||||||
lines.unshift(`var download${signals.download.downloadAlias} = await ${pageAlias}.RunAndWaitForDownloadAsync(async () =>\n{`);
|
lines.unshift(`var download${signals.download.downloadAlias} = await ${pageAlias}.RunAndWaitForDownloadAsync(async () =>\n{`);
|
||||||
|
|
@ -117,12 +116,12 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
||||||
return formatter.format();
|
return formatter.format();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _generateActionCall(action: Action, isPage: boolean): string {
|
private _generateActionCall(subject: string, action: Action): string {
|
||||||
switch (action.name) {
|
switch (action.name) {
|
||||||
case 'openPage':
|
case 'openPage':
|
||||||
throw Error('Not reached');
|
throw Error('Not reached');
|
||||||
case 'closePage':
|
case 'closePage':
|
||||||
return 'CloseAsync()';
|
return `await ${subject}.CloseAsync();`;
|
||||||
case 'click': {
|
case 'click': {
|
||||||
let method = 'Click';
|
let method = 'Click';
|
||||||
if (action.clickCount === 2)
|
if (action.clickCount === 2)
|
||||||
|
|
@ -138,27 +137,29 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
||||||
if (action.position)
|
if (action.position)
|
||||||
options.position = action.position;
|
options.position = action.position;
|
||||||
if (!Object.entries(options).length)
|
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');
|
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':
|
case 'check':
|
||||||
return this._asLocator(action.selector) + `.CheckAsync()`;
|
return `await ${subject}.${this._asLocator(action.selector)}.CheckAsync();`;
|
||||||
case 'uncheck':
|
case 'uncheck':
|
||||||
return this._asLocator(action.selector) + `.UncheckAsync()`;
|
return `await ${subject}.${this._asLocator(action.selector)}.UncheckAsync();`;
|
||||||
case 'fill':
|
case 'fill':
|
||||||
return this._asLocator(action.selector) + `.FillAsync(${quote(action.text)})`;
|
return `await ${subject}.${this._asLocator(action.selector)}.FillAsync(${quote(action.text)});`;
|
||||||
case 'setInputFiles':
|
case 'setInputFiles':
|
||||||
return this._asLocator(action.selector) + `.SetInputFilesAsync(${formatObject(action.files)})`;
|
return `await ${subject}.${this._asLocator(action.selector)}.SetInputFilesAsync(${formatObject(action.files)});`;
|
||||||
case 'press': {
|
case 'press': {
|
||||||
const modifiers = toModifiers(action.modifiers);
|
const modifiers = toModifiers(action.modifiers);
|
||||||
const shortcut = [...modifiers, action.key].join('+');
|
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':
|
case 'navigate':
|
||||||
return `GotoAsync(${quote(action.url)})`;
|
return `await ${subject}.GotoAsync(${quote(action.url)});`;
|
||||||
case 'select':
|
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)});`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,7 @@ export class JavaLanguageGenerator implements LanguageGenerator {
|
||||||
});`);
|
});`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionCall = this._generateActionCall(action, inFrameLocator);
|
let code = this._generateActionCall(subject, action, inFrameLocator);
|
||||||
let code = `${subject}.${actionCall};`;
|
|
||||||
|
|
||||||
if (signals.popup) {
|
if (signals.popup) {
|
||||||
code = `Page ${signals.popup.popupAlias} = ${pageAlias}.waitForPopup(() -> {
|
code = `Page ${signals.popup.popupAlias} = ${pageAlias}.waitForPopup(() -> {
|
||||||
|
|
@ -87,12 +86,12 @@ export class JavaLanguageGenerator implements LanguageGenerator {
|
||||||
return formatter.format();
|
return formatter.format();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _generateActionCall(action: Action, inFrameLocator: boolean): string {
|
private _generateActionCall(subject: string, action: Action, inFrameLocator: boolean): string {
|
||||||
switch (action.name) {
|
switch (action.name) {
|
||||||
case 'openPage':
|
case 'openPage':
|
||||||
throw Error('Not reached');
|
throw Error('Not reached');
|
||||||
case 'closePage':
|
case 'closePage':
|
||||||
return 'close()';
|
return `${subject}.close();`;
|
||||||
case 'click': {
|
case 'click': {
|
||||||
let method = 'click';
|
let method = 'click';
|
||||||
if (action.clickCount === 2)
|
if (action.clickCount === 2)
|
||||||
|
|
@ -108,25 +107,27 @@ export class JavaLanguageGenerator implements LanguageGenerator {
|
||||||
if (action.position)
|
if (action.position)
|
||||||
options.position = action.position;
|
options.position = action.position;
|
||||||
const optionsText = formatClickOptions(options);
|
const optionsText = formatClickOptions(options);
|
||||||
return this._asLocator(action.selector, inFrameLocator) + `.${method}(${optionsText})`;
|
return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.${method}(${optionsText});`;
|
||||||
}
|
}
|
||||||
case 'check':
|
case 'check':
|
||||||
return this._asLocator(action.selector, inFrameLocator) + `.check()`;
|
return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.check();`;
|
||||||
case 'uncheck':
|
case 'uncheck':
|
||||||
return this._asLocator(action.selector, inFrameLocator) + `.uncheck()`;
|
return `${subject}.${this._asLocator(action.selector, inFrameLocator)}.uncheck();`;
|
||||||
case 'fill':
|
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':
|
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': {
|
case 'press': {
|
||||||
const modifiers = toModifiers(action.modifiers);
|
const modifiers = toModifiers(action.modifiers);
|
||||||
const shortcut = [...modifiers, action.key].join('+');
|
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':
|
case 'navigate':
|
||||||
return `navigate(${quote(action.url)})`;
|
return `${subject}.navigate(${quote(action.url)});`;
|
||||||
case 'select':
|
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)});`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
||||||
if (signals.download)
|
if (signals.download)
|
||||||
formatter.add(`const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`);
|
formatter.add(`const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`);
|
||||||
|
|
||||||
const actionCall = this._generateActionCall(action);
|
formatter.add(this._generateActionCall(subject, action));
|
||||||
formatter.add(`await ${subject}.${actionCall};`);
|
|
||||||
|
|
||||||
if (signals.popup)
|
if (signals.popup)
|
||||||
formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`);
|
formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`);
|
||||||
|
|
@ -90,12 +89,12 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
||||||
return formatter.format();
|
return formatter.format();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _generateActionCall(action: Action): string {
|
private _generateActionCall(subject: string, action: Action): string {
|
||||||
switch (action.name) {
|
switch (action.name) {
|
||||||
case 'openPage':
|
case 'openPage':
|
||||||
throw Error('Not reached');
|
throw Error('Not reached');
|
||||||
case 'closePage':
|
case 'closePage':
|
||||||
return 'close()';
|
return `await ${subject}.close();`;
|
||||||
case 'click': {
|
case 'click': {
|
||||||
let method = 'click';
|
let method = 'click';
|
||||||
if (action.clickCount === 2)
|
if (action.clickCount === 2)
|
||||||
|
|
@ -111,25 +110,27 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
||||||
if (action.position)
|
if (action.position)
|
||||||
options.position = action.position;
|
options.position = action.position;
|
||||||
const optionsString = formatOptions(options, false);
|
const optionsString = formatOptions(options, false);
|
||||||
return this._asLocator(action.selector) + `.${method}(${optionsString})`;
|
return `await ${subject}.${this._asLocator(action.selector)}.${method}(${optionsString});`;
|
||||||
}
|
}
|
||||||
case 'check':
|
case 'check':
|
||||||
return this._asLocator(action.selector) + `.check()`;
|
return `await ${subject}.${this._asLocator(action.selector)}.check();`;
|
||||||
case 'uncheck':
|
case 'uncheck':
|
||||||
return this._asLocator(action.selector) + `.uncheck()`;
|
return `await ${subject}.${this._asLocator(action.selector)}.uncheck();`;
|
||||||
case 'fill':
|
case 'fill':
|
||||||
return this._asLocator(action.selector) + `.fill(${quote(action.text)})`;
|
return `await ${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)});`;
|
||||||
case 'setInputFiles':
|
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': {
|
case 'press': {
|
||||||
const modifiers = toModifiers(action.modifiers);
|
const modifiers = toModifiers(action.modifiers);
|
||||||
const shortcut = [...modifiers, action.key].join('+');
|
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':
|
case 'navigate':
|
||||||
return `goto(${quote(action.url)})`;
|
return `await ${subject}.goto(${quote(action.url)});`;
|
||||||
case 'select':
|
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)});`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
|
||||||
if (signals.dialog)
|
if (signals.dialog)
|
||||||
formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`);
|
formatter.add(` ${pageAlias}.once("dialog", lambda dialog: dialog.dismiss())`);
|
||||||
|
|
||||||
const actionCall = this._generateActionCall(action);
|
let code = `${this._awaitPrefix}${this._generateActionCall(subject, action)}`;
|
||||||
let code = `${this._awaitPrefix}${subject}.${actionCall}`;
|
|
||||||
|
|
||||||
if (signals.popup) {
|
if (signals.popup) {
|
||||||
code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as ${signals.popup.popupAlias}_info {
|
code = `${this._asyncPrefix}with ${pageAlias}.expect_popup() as ${signals.popup.popupAlias}_info {
|
||||||
|
|
@ -99,12 +98,12 @@ export class PythonLanguageGenerator implements LanguageGenerator {
|
||||||
return formatter.format();
|
return formatter.format();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _generateActionCall(action: Action): string {
|
private _generateActionCall(subject: string, action: Action): string {
|
||||||
switch (action.name) {
|
switch (action.name) {
|
||||||
case 'openPage':
|
case 'openPage':
|
||||||
throw Error('Not reached');
|
throw Error('Not reached');
|
||||||
case 'closePage':
|
case 'closePage':
|
||||||
return 'close()';
|
return `${subject}.close()`;
|
||||||
case 'click': {
|
case 'click': {
|
||||||
let method = 'click';
|
let method = 'click';
|
||||||
if (action.clickCount === 2)
|
if (action.clickCount === 2)
|
||||||
|
|
@ -120,25 +119,27 @@ export class PythonLanguageGenerator implements LanguageGenerator {
|
||||||
if (action.position)
|
if (action.position)
|
||||||
options.position = action.position;
|
options.position = action.position;
|
||||||
const optionsString = formatOptions(options, false);
|
const optionsString = formatOptions(options, false);
|
||||||
return this._asLocator(action.selector) + `.${method}(${optionsString})`;
|
return `${subject}.${this._asLocator(action.selector)}.${method}(${optionsString})`;
|
||||||
}
|
}
|
||||||
case 'check':
|
case 'check':
|
||||||
return this._asLocator(action.selector) + `.check()`;
|
return `${subject}.${this._asLocator(action.selector)}.check()`;
|
||||||
case 'uncheck':
|
case 'uncheck':
|
||||||
return this._asLocator(action.selector) + `.uncheck()`;
|
return `${subject}.${this._asLocator(action.selector)}.uncheck()`;
|
||||||
case 'fill':
|
case 'fill':
|
||||||
return this._asLocator(action.selector) + `.fill(${quote(action.text)})`;
|
return `${subject}.${this._asLocator(action.selector)}.fill(${quote(action.text)})`;
|
||||||
case 'setInputFiles':
|
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': {
|
case 'press': {
|
||||||
const modifiers = toModifiers(action.modifiers);
|
const modifiers = toModifiers(action.modifiers);
|
||||||
const shortcut = [...modifiers, action.key].join('+');
|
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':
|
case 'navigate':
|
||||||
return `goto(${quote(action.url)})`;
|
return `${subject}.goto(${quote(action.url)})`;
|
||||||
case 'select':
|
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)})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ export type ActionName =
|
||||||
'press' |
|
'press' |
|
||||||
'select' |
|
'select' |
|
||||||
'uncheck' |
|
'uncheck' |
|
||||||
'setInputFiles';
|
'setInputFiles' |
|
||||||
|
'assertText';
|
||||||
|
|
||||||
export type ActionBase = {
|
export type ActionBase = {
|
||||||
name: ActionName,
|
name: ActionName,
|
||||||
|
|
@ -91,7 +92,14 @@ export type SetInputFilesAction = ActionBase & {
|
||||||
files: string[],
|
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.
|
// Signals.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import type { Page } from '../page';
|
||||||
import { ProgressController } from '../progress';
|
import { ProgressController } from '../progress';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { serverSideCallMetadata } from '../instrumentation';
|
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 { isUnderTest } from '../../utils';
|
||||||
import { mime } from '../../utilsBundle';
|
import { mime } from '../../utilsBundle';
|
||||||
import { syncLocalStorageWithSettings } from '../launchApp';
|
import { syncLocalStorageWithSettings } from '../launchApp';
|
||||||
|
|
@ -44,7 +44,8 @@ declare global {
|
||||||
export interface IRecorderApp extends EventEmitter {
|
export interface IRecorderApp extends EventEmitter {
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
setPaused(paused: boolean): 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>;
|
setFileIfNeeded(file: string): Promise<void>;
|
||||||
setSelector(selector: string, focus?: boolean): Promise<void>;
|
setSelector(selector: string, focus?: boolean): Promise<void>;
|
||||||
updateCallLogs(callLogs: CallLog[]): Promise<void>;
|
updateCallLogs(callLogs: CallLog[]): Promise<void>;
|
||||||
|
|
@ -54,7 +55,8 @@ export interface IRecorderApp extends EventEmitter {
|
||||||
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
|
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
async close(): Promise<void> {}
|
async close(): Promise<void> {}
|
||||||
async setPaused(paused: boolean): 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 setFileIfNeeded(file: string): Promise<void> {}
|
||||||
async setSelector(selector: string, focus?: boolean): Promise<void> {}
|
async setSelector(selector: string, focus?: boolean): Promise<void> {}
|
||||||
async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
|
async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
|
||||||
|
|
@ -138,12 +140,18 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise<void> {
|
async setMode(mode: Mode): Promise<void> {
|
||||||
await this._page.mainFrame().evaluateExpression(((mode: Mode) => {
|
await this._page.mainFrame().evaluateExpression(((mode: Mode) => {
|
||||||
window.playwrightSetMode(mode);
|
window.playwrightSetMode(mode);
|
||||||
}).toString(), { isFunction: true }, mode).catch(() => {});
|
}).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> {
|
async setFileIfNeeded(file: string): Promise<void> {
|
||||||
await this._page.mainFrame().evaluateExpression(((file: string) => {
|
await this._page.mainFrame().evaluateExpression(((file: string) => {
|
||||||
window.playwrightSetFileIfNeeded(file);
|
window.playwrightSetFileIfNeeded(file);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
limitations under the License.
|
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 * as React from 'react';
|
||||||
import { Recorder } from './recorder';
|
import { Recorder } from './recorder';
|
||||||
import './recorder.css';
|
import './recorder.css';
|
||||||
|
|
@ -25,8 +25,10 @@ export const Main: React.FC = ({
|
||||||
const [paused, setPaused] = React.useState(false);
|
const [paused, setPaused] = React.useState(false);
|
||||||
const [log, setLog] = React.useState(new Map<string, CallLog>());
|
const [log, setLog] = React.useState(new Map<string, CallLog>());
|
||||||
const [mode, setMode] = React.useState<Mode>('none');
|
const [mode, setMode] = React.useState<Mode>('none');
|
||||||
|
const [tool, setTool] = React.useState<RecordingTool>('action');
|
||||||
|
|
||||||
window.playwrightSetMode = setMode;
|
window.playwrightSetMode = setMode;
|
||||||
|
window.playwrightSetRecordingTool = setTool;
|
||||||
window.playwrightSetSources = setSources;
|
window.playwrightSetSources = setSources;
|
||||||
window.playwrightSetPaused = setPaused;
|
window.playwrightSetPaused = setPaused;
|
||||||
window.playwrightUpdateLogs = callLogs => {
|
window.playwrightUpdateLogs = callLogs => {
|
||||||
|
|
@ -39,5 +41,5 @@ export const Main: React.FC = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
window.playwrightSourcesEchoForTest = sources;
|
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}/>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
limitations under the License.
|
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 { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
||||||
import { SplitView } from '@web/components/splitView';
|
import { SplitView } from '@web/components/splitView';
|
||||||
import { TabbedPane } from '@web/components/tabbedPane';
|
import { TabbedPane } from '@web/components/tabbedPane';
|
||||||
|
|
@ -40,6 +40,7 @@ export interface RecorderProps {
|
||||||
paused: boolean,
|
paused: boolean,
|
||||||
log: Map<string, CallLog>,
|
log: Map<string, CallLog>,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
|
tool: RecordingTool,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Recorder: React.FC<RecorderProps> = ({
|
export const Recorder: React.FC<RecorderProps> = ({
|
||||||
|
|
@ -47,6 +48,7 @@ export const Recorder: React.FC<RecorderProps> = ({
|
||||||
paused,
|
paused,
|
||||||
log,
|
log,
|
||||||
mode,
|
mode,
|
||||||
|
tool,
|
||||||
}) => {
|
}) => {
|
||||||
const [fileId, setFileId] = React.useState<string | undefined>();
|
const [fileId, setFileId] = React.useState<string | undefined>();
|
||||||
const [selectedTab, setSelectedTab] = React.useState<string>('log');
|
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={() => {
|
<ToolbarButton icon='record' title='Record' toggled={mode === 'recording'} onClick={() => {
|
||||||
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' } });
|
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' } });
|
||||||
}}>Record</ToolbarButton>
|
}}>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={() => {
|
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
|
||||||
copy(source.text);
|
copy(source.text);
|
||||||
}}></ToolbarButton>
|
}}></ToolbarButton>
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,16 @@ export type Point = { x: number, y: number };
|
||||||
|
|
||||||
export type Mode = 'inspecting' | 'recording' | 'none';
|
export type Mode = 'inspecting' | 'recording' | 'none';
|
||||||
|
|
||||||
|
export type RecordingTool = 'action' | 'assert';
|
||||||
|
|
||||||
export type EventData = {
|
export type EventData = {
|
||||||
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'fileChanged';
|
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'setRecordingTool' | 'selectorUpdated' | 'fileChanged';
|
||||||
params: any;
|
params: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UIState = {
|
export type UIState = {
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
|
tool: RecordingTool;
|
||||||
actionPoint?: Point;
|
actionPoint?: Point;
|
||||||
actionSelector?: string;
|
actionSelector?: string;
|
||||||
language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
|
language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
|
||||||
|
|
@ -72,6 +75,7 @@ export type Source = {
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
playwrightSetMode: (mode: Mode) => void;
|
playwrightSetMode: (mode: Mode) => void;
|
||||||
|
playwrightSetRecordingTool: (tool: RecordingTool) => void;
|
||||||
playwrightSetPaused: (paused: boolean) => void;
|
playwrightSetPaused: (paused: boolean) => void;
|
||||||
playwrightSetSources: (sources: Source[]) => void;
|
playwrightSetSources: (sources: Source[]) => void;
|
||||||
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
|
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,7 @@ export const InspectModeController: React.FunctionComponent<{
|
||||||
const actionSelector = locatorOrSelectorAsSelector(sdkLanguage, highlightedLocator, testIdAttributeName);
|
const actionSelector = locatorOrSelectorAsSelector(sdkLanguage, highlightedLocator, testIdAttributeName);
|
||||||
recorder.setUIState({
|
recorder.setUIState({
|
||||||
mode: isInspecting ? 'inspecting' : 'none',
|
mode: isInspecting ? 'inspecting' : 'none',
|
||||||
|
tool: 'action',
|
||||||
actionSelector: actionSelector.startsWith(frameSelector) ? actionSelector.substring(frameSelector.length).trim() : undefined,
|
actionSelector: actionSelector.startsWith(frameSelector) ? actionSelector.substring(frameSelector.length).trim() : undefined,
|
||||||
language: sdkLanguage,
|
language: sdkLanguage,
|
||||||
testIdAttributeName,
|
testIdAttributeName,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue