playwright-reselect
v0.4.3
Published
A tiny helper to wright test once and reuse the logic anywhere
Maintainers
Readme
Playwright Reselect is a small utility to define tree-shaped locator descriptors for Playwright tests, with built-in debugging helpers and chainable expectation helpers. It is designed to make end-to-end test code more readable, DRY, and easier to maintain.
Highlights
- Declarative locator trees for scoping and reuse
- Wrapped locators with
.debug(),.inspect(), and.expectChain()helpers - Type safe (auto completion of the call chain, navigate quickly to a node by "go to definition" in VS Code)
- Small, focused, and compatible with Playwright v1.57+
Advantages
- Describe the UI once, reuse with ease
- Multiple UI descriptions
- Debug the DOM easily
- Inspect any link in the chain by printing the selector of the inspected node
- Assert quickly
- Test dynamic DOM updates
- On UI changes, fix multiple tests at once by updating the UI description
Table of contents
Installation
Install from npm:
npm install --save-dev playwright-reselect
# or
pnpm add -D playwright-reselectYou will also need Playwright (the package or playwright-core) installed in your project. If you use Playwright's test runner, this integrates directly.
Quick Start
Define a locator tree and use reselectTree in your tests:
// definitions.ts
import { test } from '@playwright/test';
import { Ctx, reselectTree, defineTree } from 'playwright-reselect';
export const treeDescription = defineTree({
playwrightHomePage: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
children: {
heading: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('h1');
},
},
},
},
});
// example.spec.ts
import { test } from '@playwright/test';
import { reselectTree, type ExpectChain } from 'playwright-reselect';
import { treeDescription } from './definitions.ts';
// create a root accessor function from the description
const select = reselectTree(treeDescription);
test('heading is visible', async ({ page }) => {
await page.goto('https://playwright.dev');
// pass `page`
const root = select(page);
await root
.playwrightHomePage()
.heading()
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Playwright/));
});
API
Core concepts
defineTree(): Helper function to define your locator tree with proper type inference for aliases and autocomplete support.defineBranch(): Helper function to define individual page/branch definitions that can be extracted as constants before being added to the tree.- Tree structure: An object describing build rules for nodes. Each node must implement
build(ctx)which updatesctx.locator. Build functions don't need an explicit return statement. reselectTree(treeDescription)(page): Provide the tree description toreselectTreeto get a function that accepts a Playwrightpageand returns an object with root node accessors. Calling a node runs itsbuildand returns a node object.
Node methods
node.get()— after calling .get() you have access to all native Playwright methods with full autocomplete support, so everything you can do on a Playwright locator you can do when getting an item from the chain.node.<customName>(...args)— call a custom getter method (eg: getButtonByType) defined on the node (must acceptctxfirst and return aLocator). This is useful if you need to select multiple parts of a node.
node.debug()— print a pretty HTML snapshot of the matched element.node.expectChain()— chainable async matchers mirroring Playwrightexpect.node.inspect()— logs the locator selector chain and returns the node for chaining.node.<child>()— move into a child node defined inchildren.node.skipToAlias()— returns an object containing all aliased descendant nodes, allowing direct navigation to deeply nested nodes without traversing the full path.
Quick Tour
// assume `root` was created via `reselectTree(treeDescription)(page)`
const home = root.playwrightHomePage();
// get a wrapped locator and use Playwright API
await home.heading().title().get().click();
// debug the current node's locator (print innerHTML to inspect and grab selectors)
await home.heading().debug();
// inspect the current node's locator (print the selector)
await home
.heading()
.inspect() // print [INSPECT] :root >> body >> heading
.title()
.inspect() // print [INSPECT] :root >> body >> heading >> h1
.get()
// use chained expectations
await home.heading().title()
.expectChain()
.toBeVisible()
.then((c: ExpectChain) => c.toHaveText(/Playwright/));
// call a custom getter defined on the node (returns a wrapped locator)
await home.heading()
.gitHubLinks()
.getButtonByType('star'); // custom getterBuilding the Tree — Tips
- Keep nodes small and focused: each node should represent a meaningful UI fragment (header, list, item).
- Prefer composition over deep nesting: group related nodes under a parent rather than creating long access chains.
- Use
customgetters for repeated or parameterized selections instead of inline locators. - Return early from
buildwith the narrowest locator possible so children can scope from it.
UI Tips
- Name nodes by their role or intent (e.g.,
navMain,productCard) rather than DOM details. - Avoid brittle selectors: prefer data attributes like
data-testidwhen available. - When a UI fragment is reused across pages, keep it as a separate subtree and import it into page descriptions.
Examples
Get a node
// descriptor snapshot for this example
const treeDescription = defineTree({
playwrightHomePage: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
children: {
heading: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
},
children: {
title: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('h1');
}
}
}
}
}
}
});
const root = reselectTree(treeDescription)(page);
const home = root.playwrightHomePage();
await home
.heading()
.title()
.get() // get the title
.click();Debug current node
// descriptor snapshot for this example
const treeDescription = defineTree({
playwrightHomePage: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
children: {
heading: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
}
}
}
}
});
const root = reselectTree(treeDescription)(page);
const home = root.playwrightHomePage();
await home.heading().debug();Chained expectations
// descriptor snapshot for this example
const treeDescription = defineTree({
playwrightHomePage: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
children: {
heading: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
},
children: {
title: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('h1');
}
}
}
}
}
}
});
const root = reselectTree(treeDescription)(page);
const home = root.playwrightHomePage();
await home
.heading()
.title()
.expectChain()
.toBeVisible() // expect 1
.then((c) => c.toHaveText(/Playwright/)); // expect 2Custom getter
// descriptor snapshot with a custom getter
const treeDescription = defineTree({
playwrightHomePage: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
children: {
heading: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
},
children: {
gitHubLinks: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('.github-btn.github-stargazers');
},
custom: { // define custom getters here
getButtonByType: (ctx: Ctx, type: 'star' | 'fork') => {
return ctx.locator.locator(type === 'star' ? 'a.gh-btn' : 'a.gh-count');
}
}
}
}
}
}
}
});
const root = reselectTree(treeDescription)(page);
const home = root.playwrightHomePage();
await home
.heading()
.gitHubLinks()
.getButtonByType('star') // custom getter with arguments
.expectChain()
.toBeVisible();Reusable list with custom getter
const treeDescription = defineTree({
app: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('#app');
},
children: {
userList: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('.users');
},
custom: {
getItemByPosition: (ctx: Ctx, i: number) => {
return ctx.locator.locator(`.user:nth-child(${i})`);
},
},
},
},
},
});
const root = reselectTree(treeDescription)(page);
// Access multiple list item
await root
.app()
.userList()
.getItemByPosition(1) // <- item 1
.expectChain()
.toHaveText('First User');
await root
.app()
.userList()
.getItemByPosition(2) // <- item 2
.expectChain()
.toHaveText('Second User');Store a node in a variable and get multiple subparts from the stored variable
Use a variable to cache a subtree when you need multiple operations on it.
const home = root.playwrightHomePage();
const heading = home
.heading()
const title = await heading
.title()
.get();
const gitHubLinks = await heading
.gitHubLinks()
.get();Skip to Alias — Quick navigation to deeply nested nodes ✨ NEW
The skipToAlias() feature allows you to jump directly to deeply nested nodes without traversing the entire tree, making your tests more concise and maintainable.
✨ NEW in v0.4.0: Navigate to deeply nested nodes instantly using aliases with full TypeScript autocomplete support!
Define aliases in your tree
Tag an alias property to any node you want to access quickly:
...
nodeName: {
alias: 'aliasName',
build: (ctx: Ctx) => {
...Use skipToAlias() to jump directly to aliased nodes
Instead of traversing the entire tree, hop over! Consider this tree structure:
app
├── header
│ ├── topSection
│ │ └── headerLogo (alias: 'headerLogo') ⭐
│ └── bottomSection
│ └── menuBtn (alias: 'menuBtn') ⭐
└── content
└── navigation
└── search (alias: 'search') ⭐With the above structure, you can skip directly to aliased nodes:
// Traditional way - verbose (traversing the full path)
await root
.app()
.header()
.bottomSection()
.navigation()
.search()
// With skipToAlias - jump directly to the aliased node
await root
.app().skipToAlias()
.search()
// Preferred way to be used
// extract the aliases from the top level node
// Access multiple aliased nodes quickly
const {
headerLogo,
menuBtn,
search
} = select(page).app().skipToAlias();
Benefits
- Type-safe: Full TypeScript autocomplete for all alias names
- Scoped access:
skipToAlias()only shows aliases from descendant nodes (children and nested children) - Maintainable: Change the tree structure without updating test navigation paths
- Readable: Makes test intent clearer by using semantic alias names
Scoping rules
The skipToAlias() method provides access to aliases from descendant nodes (children and nested children) based on your current position in the tree. Here's a visual representation of the scope hierarchy:
app
├── header
│ ├── topSection
│ │ └── headerLogo (alias: 'headerLogo')
│ └── bottomSection
│ └── menuBtn (alias: 'menuBtn')
└── content
└── navigation
└── search (alias: 'search')Scoping behavior:
// From root, see all descendant aliases
const rootAliases = root.app().skipToAlias();
// ✅ rootAliases.headerLogo()
// ✅ rootAliases.menuBtn()
// ✅ rootAliases.search()
// From header, only see header's descendant aliases
const headerAliases = root.app().header().skipToAlias();
// ✅ headerAliases.headerLogo()
// ✅ headerAliases.menuBtn()
// ❌ headerAliases.search() - not a descendant of header
// From content, only see content's descendant aliases
const contentAliases = root.app().content().skipToAlias();
// ✅ contentAliases.search()
// ❌ contentAliases.headerLogo() - not a descendant of contentTypeScript Navigation Limitation in (VS Code)
Limitation: Ctrl+Click on alias method calls (e.g., .headerLogoAlias()) shows the type definition, not the actual node definition in the tree like when using a regular chain without an alias link. This is a TypeScript limitation with dynamically generated mapped types.
Workaround: Extract aliases via destructuring for better IDE navigation:
// Extract commonly used aliases
const { headerLogo, menuBtn, search } = select(page).app().skipToAlias();
// Now Ctrl+Click on the destructured variables works
await headerLogo().click();
await menuBtn().hover();
await expect(search()).toBeVisible();
// Also improves test readability
const { headerLogo, search } = select(page).app().skipToAlias();
await headerLogo().click();
await search().fill('playwright');
await search().press('Enter');Advanced
This section gives practical tips for building robust locator trees, structuring tests, and handling dynamic UI updates.
Reuse Tree Branches Across UIs
Extract shared fragments (e.g., a header) into their own subtree and embed them in multiple page trees so you only update selectors once.
// header branch - use defineBranch for individual branches
import { defineBranch } from 'playwright-reselect';
export const header = defineBranch({
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('header');
},
children: {
logo: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.getByRole('link', { name: 'Playwright' });
}
},
navDocs: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.getByRole('link', { name: 'Docs' });
}
},
},
});Import header and use it to build the tree at multiple part (write one reuse anywhere)
import { header } from './headerDescription'
const treeDescription = defineTree({
home: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
children: {
header, // <- home page header
hero: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.locator('main');
},
},
},
},
docs: {
build: (ctx: Ctx) => {
ctx.locator = ctx.page.locator('body');
},
children: {
header, // <- docs page header
sidebar: {
build: (ctx: Ctx) => {
ctx.locator = ctx.locator.getByRole('navigation');
},
},
},
},
});
const select = reselectTree(treeDescription);
// Usage
const home = select(page).home();
await home
.header()
.logo()
.expectChain()
.toBeVisible();
const docs = select(page).docs();
await docs
.header()
.navDocs()
.expectChain()
.toBeVisible();Testing Layout Setup
- Use a
beforeEachto navigate and create the root selector: keeps tests focused on assertions. - If your app needs authenticated state, perform login once in a hook and reuse that session.
import { test } from '@playwright/test';
import { reselectTree } from 'playwright-reselect';
import { treeDescription } from './definitions';
const select = reselectTree(treeDescription);
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('example', async ({ page }) => {
const root = select(page);
await root
.app()
.userList()
.getItemByPosition(1)
.expectChain()
.toBeVisible();
});Testing Dynamic Layout
- Use
expectChain()with timeouts for elements that appear asynchronously. - For complex animations or lazy loading, combine
locator.waitFor()orpage.waitForResponse()with assertions. - Use
.debug()to print the matched element HTML to quickly craft robust selectors.
// waiting for an async item
const item = await root
.app()
.userList()
.getItemByPosition(3);
await item.waitFor({ state: 'visible', timeout: 5000 });
await root
.app()
.userList()
.getItemByPosition(3)
.expectChain()
.toBeVisible();
// use debug when unsure what the locator matches
await root
.app()
.userList()
.debug();Security
If you discover a security vulnerability, please do not open a public issue. Instead, report it privately following SECURITY.md.
License
This project is licensed under the MIT License — see the LICENSE file for details.
Author and Maintainer: marcflavius [email protected]
Love making things simple. Rocket science! 🚀
