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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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}/>;
}; };

View 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 { 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>

View file

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

View file

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