Skip to content

Commit

Permalink
feat(expect): generate toHaveText (microsoft#27824)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgozman authored Oct 27, 2023
1 parent 54ebee7 commit 24deac4
Show file tree
Hide file tree
Showing 15 changed files with 284 additions and 87 deletions.
6 changes: 6 additions & 0 deletions packages/playwright-core/src/server/injected/domUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export function isInsideScope(scope: Node, element: Element | undefined): boolea
return false;
}

export function enclosingElement(node: Node) {
if (node.nodeType === 1 /* Node.ELEMENT_NODE */)
return node as Element;
return node.parentElement ?? undefined;
}

export function parentElementOrShadowHost(element: Element): Element | undefined {
if (element.parentElement)
return element.parentElement;
Expand Down
9 changes: 3 additions & 6 deletions packages/playwright-core/src/server/injected/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class Highlight {
runHighlightOnRaf(selector: ParsedSelector) {
if (this._rafRequest)
cancelAnimationFrame(this._rafRequest);
this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), stringifySelector(selector), false);
this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), stringifySelector(selector));
this._rafRequest = requestAnimationFrame(() => this.runHighlightOnRaf(selector));
}

Expand Down Expand Up @@ -144,11 +144,8 @@ export class Highlight {
this._highlightEntries = [];
}

updateHighlight(elements: Element[], selector: string, isRecording: boolean) {
let color: string;
if (isRecording)
color = '#dc6f6f7f';
else
updateHighlight(elements: Element[], selector: string, color?: string) {
if (!color)
color = elements.length > 1 ? '#f6b26b7f' : '#6fa8dc7f';
this._innerUpdateHighlight(elements, { color, tooltipText: selector ? asLocator(this._language, selector) : '' });
}
Expand Down
144 changes: 137 additions & 7 deletions packages/playwright-core/src/server/injected/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ import type * as actions from '../recorder/recorderActions';
import type { InjectedScript } from '../injected/injectedScript';
import { generateSelector } from '../injected/selectorGenerator';
import type { Point } from '../../common/types';
import type { UIState } from '@recorder/recorderTypes';
import type { UIState, Mode, RecordingTool } from '@recorder/recorderTypes';
import { Highlight } from '../injected/highlight';
import { enclosingElement, isInsideScope, parentElementOrShadowHost } from './domUtils';
import { elementText } from './selectorUtils';
import { normalizeWhiteSpace } from '@isomorphic/stringUtils';

interface RecorderDelegate {
performAction?(action: actions.Action): Promise<void>;
Expand All @@ -36,7 +39,9 @@ export class Recorder {
private _hoveredElement: HTMLElement | null = null;
private _activeModel: HighlightModel | null = null;
private _expectProgrammaticKeyUp = false;
private _mode: 'none' | 'inspecting' | 'recording' = 'none';
private _mode: Mode = 'none';
private _tool: RecordingTool = 'action';
private _selectionModel: SelectionModel | undefined;
private _actionPoint: Point | undefined;
private _actionSelector: string | undefined;
private _highlight: Highlight;
Expand Down Expand Up @@ -93,11 +98,12 @@ export class Recorder {
else
this.uninstallListeners();

const { mode, actionPoint, actionSelector, language, testIdAttributeName } = state;
const { mode, tool, actionPoint, actionSelector, language, testIdAttributeName } = state;
this._testIdAttributeName = testIdAttributeName;
this._highlight.setLanguage(language);
if (mode !== this._mode) {
if (mode !== this._mode || this._tool !== tool) {
this._mode = mode;
this._tool = tool;
this.clearHighlight();
}
if (actionPoint && this._actionPoint && actionPoint.x === this._actionPoint.x && actionPoint.y === this._actionPoint.y) {
Expand Down Expand Up @@ -126,6 +132,10 @@ export class Recorder {
clearHighlight() {
this._hoveredModel = null;
this._activeModel = null;
if (this._selectionModel) {
this._selectionModel = undefined;
this._syncDocumentSelection();
}
this._updateHighlight(false);
}

Expand Down Expand Up @@ -157,6 +167,19 @@ export class Recorder {
return;
if (this._mode === 'inspecting')
this._delegate.setSelector?.(this._hoveredModel ? this._hoveredModel.selector : '');
if (this._mode === 'recording' && this._tool === 'assert') {
if (event.detail === 1 && !this._getSelectionText()) {
const target = this._deepEventTarget(event);
const text = target ? elementText(this._injectedScript._evaluator._cacheText, target).full : '';
if (text) {
this._selectionModel = { anchor: { node: target, offset: 0 }, focus: { node: target, offset: target.childNodes.length } };
this._syncDocumentSelection();
this._updateSelectionHighlight();
}
}
consumeEvent(event);
return;
}
if (this._shouldIgnoreMouseEvent(event))
return;
if (this._actionInProgress(event))
Expand Down Expand Up @@ -202,11 +225,32 @@ export class Recorder {
return false;
}

private _selectionPosition(event: MouseEvent) {
if ((this.document as any).caretPositionFromPoint) {
const range = (this.document as any).caretPositionFromPoint(event.clientX, event.clientY);
return range ? { node: range.offsetNode, offset: range.offset } : undefined;
}
if ((this.document as any).caretRangeFromPoint) {
const range = this.document.caretRangeFromPoint(event.clientX, event.clientY);
return range ? { node: range.startContainer, offset: range.startOffset } : undefined;
}
}

private _onMouseDown(event: MouseEvent) {
if (!event.isTrusted)
return;
if (this._shouldIgnoreMouseEvent(event))
return;
if (this._mode === 'recording' && this._tool === 'assert') {
const pos = this._selectionPosition(event);
if (pos && event.detail <= 1) {
this._selectionModel = { anchor: pos, focus: pos };
this._syncDocumentSelection();
this._updateSelectionHighlight();
}
consumeEvent(event);
return;
}
if (!this._performingAction)
consumeEvent(event);
this._activeModel = this._hoveredModel;
Expand All @@ -217,6 +261,10 @@ export class Recorder {
return;
if (this._shouldIgnoreMouseEvent(event))
return;
if (this._mode === 'recording' && this._tool === 'assert') {
consumeEvent(event);
return;
}
if (!this._performingAction)
consumeEvent(event);
}
Expand All @@ -226,6 +274,18 @@ export class Recorder {
return;
if (this._mode === 'none')
return;
if (this._mode === 'recording' && this._tool === 'assert') {
if (!event.buttons)
return;
const pos = this._selectionPosition(event);
if (pos && this._selectionModel) {
this._selectionModel.focus = pos;
this._syncDocumentSelection();
this._updateSelectionHighlight();
}
consumeEvent(event);
return;
}
const target = this._deepEventTarget(event);
if (this._hoveredElement === target)
return;
Expand All @@ -246,6 +306,8 @@ export class Recorder {
private _onFocus(userGesture: boolean) {
if (this._mode === 'none')
return;
if (this._mode === 'recording' && this._tool === 'assert')
return;
const activeElement = this._deepActiveElement(this.document);
// Firefox dispatches "focus" event to body when clicking on a backgrounded headed browser window.
// We'd like to ignore this stray event.
Expand Down Expand Up @@ -273,10 +335,49 @@ export class Recorder {
this._updateHighlight(true);
}

private _getSelectionText() {
this._syncDocumentSelection();
// TODO: use elementText() passing |range=selection.getRangeAt(0)| for proper text.
return normalizeWhiteSpace(this.document.getSelection()?.toString() || '');
}

private _syncDocumentSelection() {
if (!this._selectionModel) {
this.document.getSelection()?.empty();
return;
}
this.document.getSelection()?.setBaseAndExtent(
this._selectionModel.anchor.node,
this._selectionModel.anchor.offset,
this._selectionModel.focus.node,
this._selectionModel.focus.offset,
);
}

private _updateSelectionHighlight() {
if (!this._selectionModel)
return;
const focusElement = enclosingElement(this._selectionModel.focus.node);
let lcaElement = focusElement ? enclosingElement(this._selectionModel.anchor.node) : undefined;
while (lcaElement && !isInsideScope(lcaElement, focusElement))
lcaElement = parentElementOrShadowHost(lcaElement);
const highlight = lcaElement ? generateSelector(this._injectedScript, lcaElement, { testIdAttributeName: this._testIdAttributeName, forTextExpect: true }) : undefined;
if (highlight?.selector === this._selectionModel.highlight?.selector)
return;
this._selectionModel.highlight = highlight;
this._updateHighlight(false);
}

private _updateHighlight(userGesture: boolean) {
const elements = this._hoveredModel ? this._hoveredModel.elements : [];
const selector = this._hoveredModel ? this._hoveredModel.selector : '';
this._highlight.updateHighlight(elements, selector, this._mode === 'recording');
const model = this._selectionModel?.highlight ?? this._hoveredModel;
const elements = model?.elements ?? [];
const selector = model?.selector ?? '';
let color: string | undefined;
if (model === this._selectionModel?.highlight)
color = '#6fdcbd38';
else if (this._mode === 'recording')
color = '#dc6f6f7f';
this._highlight.updateHighlight(elements, selector, color);
if (userGesture)
this._delegate.highlightUpdated?.();
}
Expand Down Expand Up @@ -363,6 +464,29 @@ export class Recorder {
}
if (this._mode !== 'recording')
return;
if (this._mode === 'recording' && this._tool === 'assert') {
if (event.key === 'Escape') {
this._selectionModel = undefined;
this._syncDocumentSelection();
this._updateHighlight(false);
} else if (event.key === 'Enter') {
if (this._selectionModel?.highlight) {
const text = this._getSelectionText();
this._delegate.recordAction?.({
name: 'assertText',
selector: this._selectionModel.highlight.selector,
signals: [],
text,
substring: normalizeWhiteSpace(elementText(this._injectedScript._evaluator._cacheText, this._selectionModel.highlight.elements[0]).full) !== text,
});
this._selectionModel = undefined;
this._syncDocumentSelection();
this._updateHighlight(false);
}
}
consumeEvent(event);
return;
}
if (!this._shouldGenerateKeyPressFor(event))
return;
if (this._actionInProgress(event)) {
Expand Down Expand Up @@ -474,6 +598,12 @@ type HighlightModel = {
elements: Element[];
};

type SelectionModel = {
anchor: { node: Node, offset: number };
focus: { node: Node, offset: number };
highlight?: HighlightModel;
};

function asCheckbox(node: Node | null): HTMLInputElement | null {
if (!node || node.nodeName !== 'INPUT')
return null;
Expand Down
46 changes: 32 additions & 14 deletions packages/playwright-core/src/server/injected/selectorGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,36 @@ const kCSSTagNameScore = 530;
const kNthScore = 10000;
const kCSSFallbackScore = 10000000;

const kScoreThresholdForTextExpect = 1000;

export type GenerateSelectorOptions = {
testIdAttributeName: string;
omitInternalEngines?: boolean;
root?: Element | Document;
forTextExpect?: boolean;
};

export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } {
injectedScript._evaluator.begin();
beginAriaCaches();
try {
targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement;
const targetTokens = generateSelectorFor(injectedScript, targetElement, options);
let targetTokens: SelectorToken[];
if (options.forTextExpect) {
targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options);
for (let element: Element | undefined = targetElement; element; element = parentElementOrShadowHost(element)) {
const tokens = generateSelectorFor(injectedScript, element, options);
if (!tokens)
continue;
const score = combineScores(tokens);
if (score <= kScoreThresholdForTextExpect) {
targetTokens = tokens;
break;
}
}
} else {
targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement;
targetTokens = generateSelectorFor(injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options);
}
const selector = joinTokens(targetTokens);
const parsedSelector = injectedScript.parseSelector(selector);
return {
Expand All @@ -91,7 +109,7 @@ function filterRegexTokens(textCandidates: SelectorToken[][]): SelectorToken[][]
return textCandidates.filter(c => c[0].selector[0] !== '/');
}

function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] {
function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] | null {
if (options.root && !isInsideScope(options.root, targetElement))
throw new Error(`Target element must belong to the root's subtree`);

Expand Down Expand Up @@ -170,7 +188,7 @@ function generateSelectorFor(injectedScript: InjectedScript, targetElement: Elem
return value;
};

return calculateCached(targetElement, true) || cssFallback(injectedScript, targetElement, options);
return calculate(targetElement, !options.forTextExpect);
}

function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, options: GenerateSelectorOptions): SelectorToken[] {
Expand Down Expand Up @@ -227,19 +245,9 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element,
if (ariaRole && !['none', 'presentation'].includes(ariaRole))
candidates.push({ engine: 'internal:role', selector: ariaRole, score: kRoleWithoutNameScore });

if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) {
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, false)}]`, score: kAltTextScore });
candidates.push({ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, true)}]`, score: kAltTextScoreExact });
}

if (element.getAttribute('name') && ['BUTTON', 'FORM', 'FIELDSET', 'FRAME', 'IFRAME', 'INPUT', 'KEYGEN', 'OBJECT', 'OUTPUT', 'SELECT', 'TEXTAREA', 'MAP', 'META', 'PARAM'].includes(element.nodeName))
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[name=${quoteAttributeValue(element.getAttribute('name')!)}]`, score: kCSSInputTypeNameScore });

if (element.getAttribute('title')) {
candidates.push({ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(element.getAttribute('title')!, false)}]`, score: kTitleScore });
candidates.push({ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(element.getAttribute('title')!, true)}]`, score: kTitleScoreExact });
}

if (['INPUT', 'TEXTAREA'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden') {
if (element.getAttribute('type'))
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[type=${quoteAttributeValue(element.getAttribute('type')!)}]`, score: kCSSInputTypeNameScore });
Expand All @@ -257,6 +265,16 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
return [];
const candidates: SelectorToken[][] = [];

if (element.getAttribute('title')) {
candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(element.getAttribute('title')!, false)}]`, score: kTitleScore }]);
candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(element.getAttribute('title')!, true)}]`, score: kTitleScoreExact }]);
}

if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) {
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, false)}]`, score: kAltTextScore }]);
candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!, true)}]`, score: kAltTextScoreExact }]);
}

const fullText = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full);
const text = fullText.substring(0, 80);
if (text) {
Expand Down
Loading

0 comments on commit 24deac4

Please sign in to comment.