playwright-reselect
Version:
A tiny helper to wright test once and reuse the logic anywhere
693 lines (592 loc) • 18.9 kB
text/typescript
import { test, expect } from '@playwright/test';
import { reselectTree, type ExpectChain, defineTree, defineBranch, type Ctx } from 'playwright-reselect';
test.describe('playwright-reselect', () => {
test.describe('basic functionality', () => {
test('should create a simple tree with one node', async ({ page }) => {
await page.setContent('<body><div>Test content</div></body>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
},
});
const select = reselectTree(tree);
const node = select(page).root();
expect(node).toBeDefined();
expect(node.get).toBeDefined();
expect(node.debug).toBeDefined();
expect(node.expectChain).toBeDefined();
expect(node.inspect).toBeDefined();
expect(node.skipToAlias).toBeDefined();
});
test('should navigate through children', async ({ page }) => {
await page.setContent('<body><div>Child content</div></body>');
const tree = defineTree({
parent: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
children: {
child: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('div');
},
},
},
},
});
const select = reselectTree(tree);
const childNode = select(page).parent().child();
expect(childNode).toBeDefined();
expect(childNode.get).toBeDefined();
});
test('should call custom methods', async ({ page }) => {
await page.setContent('<div><button id="btn1">Button 1</button><button id="btn2">Button 2</button></div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('div');
},
custom: {
getButtonById: (ctx: Ctx, id: string) => {
return ctx.locator.locator(`#${id}`);
},
},
},
});
const select = reselectTree(tree);
const button = select(page).root().getButtonById('btn1');
await expect(button).toHaveText('Button 1');
});
});
test.describe('skipToAlias functionality', () => {
test('should navigate to aliased node', async ({ page }) => {
await page.setContent('<div><header><h1>Title</h1></header></div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('div');
},
children: {
header: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
},
children: {
title: {
alias: 'pageTitle',
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('h1');
},
},
},
},
},
},
});
const select = reselectTree(tree);
const titleViaAlias = select(page).root().skipToAlias().pageTitle();
const titleViaChain = select(page).root().header().title();
// Both should work and point to the same element
await expect(titleViaAlias.get()).toHaveText('Title');
await expect(titleViaChain.get()).toHaveText('Title');
});
test('should only show descendant aliases', async ({ page }) => {
await page.setContent('<div><header><h1>Title</h1></header><footer><p>Footer</p></footer></div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('div');
},
children: {
header: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
},
children: {
title: {
alias: 'headerTitle',
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('h1');
},
},
},
},
footer: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('footer');
},
children: {
text: {
alias: 'footerText',
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('p');
},
},
},
},
},
},
});
const select = reselectTree(tree);
// From header, should only see headerTitle, not footerText
const headerAliases = select(page).root().header().skipToAlias();
expect(headerAliases.headerTitle).toBeDefined();
expect((headerAliases as any).footerText).toBeUndefined();
// From footer, should only see footerText, not headerTitle
const footerAliases = select(page).root().footer().skipToAlias();
expect(footerAliases.footerText).toBeDefined();
expect((footerAliases as any).headerTitle).toBeUndefined();
// From root, should see both
const rootAliases = select(page).root().skipToAlias();
const headerTitle = rootAliases.headerTitle;
console.info('@@headerTitle', headerTitle)
expect(headerTitle).toBeDefined();
expect(rootAliases.footerText).toBeDefined();
});
test('should return full subtree from alias', async ({ page }) => {
await page.setContent('<div><section><h1>Title</h1><button>Click</button></section></div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('div');
},
children: {
section: {
alias: 'mySection',
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('section');
},
children: {
title: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('h1');
},
},
button: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('button');
},
},
},
},
},
},
});
const select = reselectTree(tree);
// Navigate via alias and access children
const aliasedSection = select(page).root().skipToAlias().mySection();
// Should have access to all children
expect(aliasedSection.title).toBeDefined();
expect(aliasedSection.button).toBeDefined();
await expect(aliasedSection.title().get()).toHaveText('Title');
await expect(aliasedSection.button().get()).toHaveText('Click');
});
test('should work with custom methods on aliased nodes', async ({ page }) => {
await page.setContent('<div><ul><li id="a">A</li><li id="b">B</li></ul></div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('div');
},
children: {
list: {
alias: 'itemList',
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('ul');
},
custom: {
getItemById: (ctx: Ctx, id: string) => {
return ctx.locator.locator(`#${id}`);
},
},
},
},
},
});
const select = reselectTree(tree);
// Access custom method via alias
const item = select(page).root().skipToAlias().itemList().getItemById('a');
await expect(item).toHaveText('A');
});
});
test.describe('get, debug, inspect methods', () => {
test('should return locator with get()', async ({ page }) => {
await page.setContent('<div id="test">Content</div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('#test');
},
},
});
const select = reselectTree(tree);
const locator = select(page).root().get();
await expect(locator).toHaveText('Content');
expect(locator.debug).toBeDefined();
expect(locator.expectChain).toBeDefined();
});
test('should support inspect()', async ({ page }) => {
const consoleSpy: string[] = [];
const originalLog = console.log;
console.log = (...args: any[]) => {
consoleSpy.push(args.join(' '));
};
await page.setContent('<div id="test">Content</div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('#test');
},
},
});
const select = reselectTree(tree);
const node = select(page).root().inspect();
expect(node).toBeDefined();
expect(consoleSpy.some(log => log.includes('[INSPECT]'))).toBe(true);
console.log = originalLog;
});
test('should support debug()', async ({ page }) => {
await page.setContent('<div id="test">Content</div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('#test');
},
},
});
const select = reselectTree(tree);
const output = await select(page).root().debug();
expect(output).toContain('[DEBUG]');
expect(output).toContain('Content');
});
test('should support expectChain()', async ({ page }) => {
await page.setContent('<div id="test">Content</div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('#test');
},
},
});
const select = reselectTree(tree);
await select(page)
.root()
.expectChain()
.toBeVisible()
.then((chain: ExpectChain) => chain.toHaveText('Content'));
});
});
test.describe('error handling', () => {
test('should throw error when page is missing', () => {
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
},
});
const select = reselectTree(tree);
expect(() => {
select(null as any);
}).toThrow('page is required');
});
});
test.describe('complex scenarios', () => {
test('should handle deeply nested navigation', async ({ page }) => {
await page.setContent('<div><section><article><p>Deep content</p></article></section></div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('div');
},
children: {
section: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('section');
},
children: {
article: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('article');
},
children: {
paragraph: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('p');
},
},
},
},
},
},
},
},
});
const select = reselectTree(tree);
const paragraph = select(page).root().section().article().paragraph();
await expect(paragraph.get()).toHaveText('Deep content');
});
test('should handle multiple custom methods', async ({ page }) => {
await page.setContent('<div><button class="primary">Primary</button><button class="secondary">Secondary</button></div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('div');
},
custom: {
getPrimary: (ctx: Ctx) => {
return ctx.locator.locator('.primary');
},
getSecondary: (ctx: Ctx) => {
return ctx.locator.locator('.secondary');
},
},
},
});
const select = reselectTree(tree);
const node = select(page).root();
await expect(node.getPrimary()).toHaveText('Primary');
await expect(node.getSecondary()).toHaveText('Secondary');
});
test('should handle mix of children and custom methods', async ({ page }) => {
await page.setContent('<div><header><h1>Title</h1></header><button id="btn">Click</button></div>');
const tree = defineTree({
root: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('div');
},
children: {
header: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
},
children: {
title: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('h1');
},
},
},
},
},
custom: {
getButton: (ctx: Ctx) => {
return ctx.locator.locator('#btn');
},
},
},
});
const select = reselectTree(tree);
const node = select(page).root();
await expect(node.header().title().get()).toHaveText('Title');
await expect(node.getButton()).toHaveText('Click');
});
});
test.describe('integration tests with stubbed content', () => {
const createTree = () => {
const playwrightDocsPage = defineBranch({
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('body');
},
children: {
navigation: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.getByRole('navigation', { name: 'Main' });
},
},
},
});
const playwrightHomePage = defineBranch({
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('body');
},
children: {
heading: {
alias: 'pageHeading',
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
},
children: {
title: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('h1');
},
},
startedButton: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('text=Get Started');
},
},
gitHubLinks: {
alias: 'gitHubLinks',
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('.github-btn.github-stargazers');
},
custom: {
getSome: (ctx: Ctx, num: number) => {
return ctx.locator.locator(`a.gh-btn >> nth=${num}`);
},
getButtonByType: (ctx: Ctx, type: 'star' | 'fork') => {
const className = type === 'star' ? '.gh-btn' : '.gh-count';
return ctx.locator.locator(`a${className}`)
}
},
},
},
},
},
});
return defineTree({
playwrightDocsPage,
playwrightHomePage,
});
};
test.beforeEach(async ({ page }) => {
await page.setContent(`
<body>
<header>
<h1>Playwright enables reliable end-to-end testing for modern web apps.</h1>
<button>Get Started</button>
<div class="github-btn github-stargazers">
<a class="gh-btn" href="https://github.com/microsoft/playwright">Star</a>
<a class="gh-count">12.5k+</a>
</div>
</header>
</body>
`);
});
test('should test heading with chained expectations', async ({ page }) => {
const select = reselectTree(createTree());
await select(page)
.playwrightHomePage()
.heading()
.title()
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Playwright/));
});
test('should navigate between pages', async ({ page }) => {
const select = reselectTree(createTree());
const homePage = select(page);
const homePageHeading = homePage.playwrightHomePage().heading();
await homePageHeading
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Playwright/));
// Simulate navigation by changing content
await homePageHeading.startedButton().get().click();
// Stub the docs page content
await page.setContent(`
<body>
<nav role="navigation" aria-label="Main">
<a href="/">Playwright</a>
<a href="/docs/intro">Docs</a>
<a href="/api/intro">API</a>
<a href="/nodejs">Node.js</a>
<a href="/community">Community</a>
</nav>
</body>
`);
const docPage = select(page);
const documentationNavigation = docPage.playwrightDocsPage().navigation();
await documentationNavigation
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Playwright/))
.then((c: ExpectChain) => c.toHaveText(/Docs/))
.then((c: ExpectChain) => c.toHaveText(/API/))
.then((c: ExpectChain) => c.toHaveText(/Node.js/))
.then((c: ExpectChain) => c.toHaveText(/Community/));
});
test('should work with custom getter methods', async ({ page }) => {
const select = reselectTree(createTree());
const homePageHeading = select(page).playwrightHomePage().heading();
await homePageHeading
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Playwright/));
await homePageHeading.gitHubLinks().getButtonByType('star')
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Star/));
await homePageHeading.gitHubLinks().getButtonByType('fork')
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toContainText('k+'));
});
test('should support debug method', async ({ page }) => {
const select = reselectTree(createTree());
const homePageHeading = select(page).playwrightHomePage().heading();
await homePageHeading.gitHubLinks().getButtonByType('star')
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Star/));
const res = await homePageHeading.gitHubLinks().getButtonByType('star')
.debug();
expect(res).toContain('[DEBUG]');
expect(res).toContain('Star');
expect(res).toContain('gh-btn');
});
test('should support debug chaining with then', async ({ page }) => {
const select = reselectTree(createTree());
const homePageHeading = select(page).playwrightHomePage().heading();
await homePageHeading.gitHubLinks().get().waitFor({ state: 'visible', timeout: 5000 });
await homePageHeading.gitHubLinks().getButtonByType('star')
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Star/));
const res1 = await homePageHeading.gitHubLinks().getButtonByType('star')
.debug()
await homePageHeading.gitHubLinks().getButtonByType('star')
.debug()
.then((res) => expect(res).toBe(res1));
});
test('should have same output for debug() and get().debug()', async ({ page }) => {
const select = reselectTree(createTree());
const homePageHeading = select(page).playwrightHomePage().heading();
await homePageHeading.gitHubLinks().get().waitFor({ state: 'visible', timeout: 5000 });
await homePageHeading.gitHubLinks().getButtonByType('star')
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Star/));
const res1 = await homePageHeading
.gitHubLinks()
.get()
.debug()
const res2 = await homePageHeading.gitHubLinks()
.debug()
expect(res1).toBe(res2);
});
test('should support inspect method', async ({ page }) => {
const select = reselectTree(createTree());
const root = select(page);
const homePageHeading = root
.playwrightHomePage()
.inspect()
.heading();
await homePageHeading
.inspect()
.gitHubLinks()
.inspect()
.getButtonByType('star')
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Star/));
});
test('should wait for dynamic content', async ({ page }) => {
const select = reselectTree(createTree());
const homePageHeading = select(page).playwrightHomePage().heading();
await homePageHeading.gitHubLinks().get().waitFor({ state: 'visible', timeout: 5000 });
await homePageHeading.gitHubLinks().getButtonByType('star')
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Star/));
await homePageHeading.gitHubLinks().getButtonByType('fork')
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toContainText('k+'));
});
});
});