playwright icon indicating copy to clipboard operation
playwright copied to clipboard

[Feature] Add these cypress like locators

Open RantiBaba opened this issue 1 year ago • 17 comments

I’ve been using Cypress for a long time now and have enjoyed it quite a lot. A colleague asked that I try Playwright, and I must say WOW!, just WOW!

I wouldn’t say I’ve fallen in love with Playwright yet as I’m still experimenting with it at the moment however I’m liking it already. I think there a a few things Cypress did to make testing easy especially their locator commands. I think Playwright might just win me over completely if they add similar Cypress commands like: contain(), parents(), parentsUntil(), sibling(), next(), prev(), children(), within(), etc.

RantiBaba avatar Jun 14 '23 21:06 RantiBaba

Related request https://github.com/microsoft/playwright/issues/16155

yury-s avatar Jun 14 '23 21:06 yury-s

Also parent(), in addition to parents()

harmin-parra avatar Nov 02 '23 17:11 harmin-parra

also scrolling

scrollTo('bottom')
scrollTo('topRight', { duration: 2000 })
scrollTo('center', { easing: 'linear' })
scrollTo(250, 250)
scrollTo('75%', '25%')

harmin-parra avatar Nov 03 '23 03:11 harmin-parra

Please, this feature will be great

lucasdonato avatar Nov 21 '23 17:11 lucasdonato

It will be wonderful

WeberLucas avatar Nov 21 '23 18:11 WeberLucas

this would be game changer!

joaoh9 avatar Dec 31 '23 22:12 joaoh9

This feature would be greatly appreciated as these days frameworks like Mui are making testing really challenging because of the agnostic way of creating selectors that are rarely unique (I have used xpath as fallback plan however very ugly solution)

fasatrix avatar Feb 26 '24 09:02 fasatrix

This will be great to have (moving from Testcafe to Playwright and sometimes it is harder to go bottom-up to find an element which appears several times in the page and it has dynamic ids and no other element that can be used). For example : editing an element in the page (there are many) and the only thing that makes the difference between them is some specific text set by me which is the last element in the DOM.

marinamarin91 avatar Mar 20 '24 13:03 marinamarin91

Adding parent(), parents(), child(), children(), sibling() would be so good. It would allow us to traverse the DOM with native Playwright functionality. Working on a multi-language site, I need to either flood my page with test ids, rely on css selectors or xpath.

cctui-dev avatar Apr 29 '24 08:04 cctui-dev

It will be great to have this feature !

bogdanbotin avatar May 02 '24 05:05 bogdanbotin

It's almost 1 year since this feature task is open. Will it be taken into consideration soon? Thank you!

marinamarin91 avatar May 02 '24 05:05 marinamarin91

This would make my testing much easier!

sindresteen avatar May 02 '24 06:05 sindresteen

Think it's a little crazy how these things were not built-in to begin with.

basickarl avatar May 15 '24 09:05 basickarl

While not available out of the box, I believe it might be possible to polyfill the missing functionality by adding custom selectors. I do agree it would be nice to have it out-of-the-box, but we don't have that yet… If my time permits, I might give it a shot.

alexkuc avatar May 15 '24 14:05 alexkuc

A sibling locator would be a gamechanger.

nissanthen avatar May 24 '24 22:05 nissanthen

This will reduce my locator params length sooooo much. We need these, especially the parent() method

lamlamla avatar Jun 19 '24 09:06 lamlamla

This is my take at tackling this issue. Below is a quick example and the source code follows. I don't have the capacity to maintain a FOSS library hence providing the source directly. If someone wants to take this code (given that credit will be given) and convert it to a library, by all means! I've briefly tested the code and it seems okay but it is possible there are bugs in it…

How-to example

test('Sample', async ({ page }) => {
	const locator = page.getByRole('heading');

	const query = find(locator); // will try 5 times by default
	const query2 = find(locator, 99); // will try 99 before throwing an error

	query.children(); // get all children of locator (@return Locator[])
	query.parent(); // get immediate parent of the locator (@return Locator)
	query.parent.until.class(''); // keep iterating "up" until parent matches target class (without leading dot!) (@return Locator)
	query.parent.until.id(''); // keep iterating "up" until parent matches target id (without leading #!) (@return Locator)
	query.parent.until.tag(''); // keep iterating "up" until parent matches target tag (@return Locator)
	query.self(); // points to itself, just for completeness sake (@return Locator)
	query.sibling.all(); // get all siblings of locator, excludes self by default (@return Locator[])
	query.sibling.next(); // get the next sibling (@return Locator)
	query.sibling.next.all(); // get all next siblings (@return Locator[])
	query.sibling.next.until.class(''); // keep iterating "forward" until sibling matches target class (without leading dot!) (@return Locator)
	query.sibling.next.until.id(''); // keep iterating "forward" until sibling matches target id (without leading #!) (@return Locator)
	query.sibling.next.until.tag(''); // keep iterating "forward" until sibling matches target id (@return Locator)
	query.sibling.prev(); // get previous sibling of locator (@return Locator)
	query.sibling.prev.all(); // get all previous siblings (@return Locator[])
	query.sibling.prev.until.class(''); // keep iterating "backwards" until sibling matches target class (without leading dot!) (@return Locator)
	query.sibling.prev.until.id(''); // keep iterating "backwards" until sibling matches target id (without leading #!) (@return Locator)
	query.sibling.prev.until.tag; // keep iterating "backwards" until sibling matches target tag (@return Locator)
});
find.ts

import { xpath } from './xpath';
import { iterator } from './iterator';

import type { Locator } from '@playwright/test';

/**
 * Inpspired by Cypress queries
 * @param source Starting locator
 * @param tries How many times to iterate before throwing an error (default: `5`)
 * @link https://docs.cypress.io/api/table-of-contents#Queries
 */
export function find(source: Locator, tries: number = 5) {
	const locator = augmentLocator(source);
	const run = iterator(locator, tries);
	const { query } = xpath;

	const sanitize = {
		/**
		 * Remove any non alphabetic characters from the given string
		 */
		tag: (input: string): string => input.replaceAll(/[^A-z]/g, ''),
	};

	const findSelf = () => locator.xpath(query.self);

	const findChilden = () => locator.xpath(query.children).all();

	const findRoot = () => locator.xpath(query.root);

	const findParent = Object.assign(() => locator.xpath(query.parent), {
		until: {
			/**
			 * @param parent Parent ID (full match & case sensitive)
			 */
			id: (parent: string): Promise<Locator> => {
				return run(query.parent, (el, id) => el.id === id, parent);
			},
			/**
			 * @param parent CSS class (full match & case sensitive!)
			 */
			class: (parent: string): Promise<Locator> => {
				return run(query.parent, (el, css) => el.classList.contains(css), parent);
			},
			/**
			 * @param parent Tag for which we will search, e.g. `<span>` or `img
			 */
			tag: (parent: string): Promise<Locator> => {
				const tag = sanitize.tag(parent).toUpperCase();
				return run(query.parent, (el, tag) => el.tagName === tag, tag);
			},
		},
	});

	const sibling = {
		next: locator.xpath(query.sibling.next),
		prev: locator.xpath(query.sibling.previous),
	};

	const findSibling = {
		/**
		 * @param includingSelf Should the source [locator](https://playwright.dev/docs/api/class-locator) be included or not? (default `false`)
		 */
		all: async (includingSelf: boolean = false) => {
			const prev = await sibling.prev.all();
			const next = await sibling.next.all();
			if (!includingSelf) return [...prev, ...next];
			return [...prev, locator, ...next];
		},
		next: Object.assign(() => sibling.next.first(), {
			all: () => sibling.next.all(),
			until: {
				id: (id: string) => {
					return run(query.sibling.next, (el, id) => el.id === id, id);
				},
				/**
				 * @param css Target css class (case sensitive & full match!)
				 */
				class: (css: string): Promise<Locator> => {
					return run(query.sibling.next, (el, css) => el.classList.contains(css), css);
				},
				/**
				 * @param tag Tag for which we will search, e.g. `<span>` or `img
				 */
				tag: (tag: string): Promise<Locator> => {
					const targetTag = sanitize.tag(tag).toUpperCase();
					return run(query.sibling.next, (el, tag) => el.tagName === tag, targetTag);
				},
			},
		}),
		prev: Object.assign(() => sibling.prev.last(), {
			all: () => sibling.prev.all(),
			until: {
				id: (id: string) => {
					return run(query.sibling.previous, (el, id) => el.id === id, id);
				},
				class: (css: string) => {
					return run(query.sibling.previous, (el, css) => el.classList.contains(css), css);
				},
				tag: (tag: string) => {
					const targetTag = sanitize.tag(tag).toUpperCase();
					return run(query.sibling.previous, (el, tag) => el.tagName === tag, targetTag);
				},
			},
		}),
	};

	return {
		self: findSelf,
		/**
		 * Implementing root xpath locator in Playwright is problematic
		 * because unless the locator is node type 'document', query
		 * will *always* prepend a leading dot making it relative
		 * @link https://github.com/microsoft/playwright/blob/e1e6c287226e4503e04b1b704ba370b9177a6209/packages/playwright-core/src/server/injected/xpathSelectorEngine.ts#L21-L22
		 */
		// root: findRoot,
		children: findChilden,
		parent: findParent,
		sibling: findSibling,
	};
}

interface AugmentedLocator extends Locator {
	/**
	 * Execute XPath query
	 * @param query XPath query
	 */
	xpath: (query: string) => Locator;
}

/**
 * Add method `xpath` to [locator](https://playwright.dev/docs/api/class-locator)
 * @param locator
 */
const augmentLocator = (locator: Locator): AugmentedLocator => {
	return Object.assign(locator, {
		xpath: (query: string) => {
			if (!query.startsWith('xpath=')) query = 'xpath=' + query;
			return locator.locator(query);
		},
	});
};
iterator.ts

import type { ElementHandle, JSHandle, Locator } from '@playwright/test';
import type { Serializable } from 'child_process';

/**
 * This function "iterates" (do-while loop) until the given predicate is fullfilled or the number of allowed tries is reached and an error is thrown
 * @param locator Source locator
 * @param tries How many times to iterate before throwing an error
 */
export function iterator(locator: Locator, tries: number = 5) {
	/**
	 * "Iterate" (do-while loop) until the `evaluateFn` either returns `true` or the allowed number of `tries` is reached and an error is thrown
	 * @param selector XPath selector which would allow recursive looping, e.g. `parent::node()` or `following-sibling::node()`
	 * @param evaluateFn A predicate function which receives the current element as its first argument and the custom supplied argument as its second argument. Since this function is run *inside* whatever browser Playwright is using, it will *not* inherit scope, which is why we need to supply the custom argument manually (if one is required)
	 * @param argument Custom argument we supply to the predicate function (optional, defaults to `undefined`). Has to be [a serializable object](https://developer.mozilla.org/en-US/docs/Glossary/Serializable_object) so live DOM nodes are no-go. See [this](https://stackoverflow.com/a/69183016/4343719) StackOverflow answer (even though it's for Puppeteer the same principle still applies to Playwright).
	 */
	return async <E extends Element = Element, A extends Argument = Argument>(
		selector: string,
		evaluateFn: ((element: E, argument: A) => Promise<boolean>) | ((element: E, argument: A) => boolean),
		argument: A = undefined as A
	): Promise<Locator> => {
		const query = makeXPathQuery(selector);
		let targetLocator: Locator = locator;
		let count: number = 0;

		do {
			targetLocator = targetLocator.locator(query).first();
			const result = await targetLocator.evaluate(evaluateFn, argument);
			if (result) return targetLocator;
		} while (count !== tries);

		throw new Error('Internal error!',);
	};
}

const makeXPathQuery = (selector: string): string => {
	return selector.startsWith('xpath=') ? selector : 'xpath=' + selector;
};

type Element = SVGElement | HTMLElement;
type Argument = Serializable | JSHandle | ElementHandle | undefined;
xpath.ts

const queries = {
	root: '/',
	self: 'self::node()',
	parent: 'parent::node()',
	children: 'self::node()/child::node()',
	sibling: {
		next: 'self::node()/following-sibling::node()',
		previous: 'self::node()/preceding-sibling::node()',
	},
} as const;

export const xpath = {
	query: queries,
};

alexkuc avatar Jun 19 '24 15:06 alexkuc