playwright-reselect
Version:
A tiny helper to wright test once and reuse the logic anywhere
686 lines (553 loc) • 19.5 kB
Markdown
<div align="center">
<h1>Playwright Reselect</h1>
<p><em>A tiny helper to write tests once and reuse the logic anywhere.</em></p>
</div>
<p align="center">
<a href="https://www.npmjs.com/package/playwright-reselect"><img src="https://img.shields.io/npm/v/playwright-reselect.svg" alt="npm version"></a>
<a href="https://www.npmjs.com/package/playwright-reselect"><img src="https://img.shields.io/npm/dm/playwright-reselect.svg" alt="npm downloads"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-Ready-blue.svg" alt="TypeScript"></a>
<a href="https://playwright.dev"><img src="https://img.shields.io/badge/Playwright-1.57+-45ba4b.svg" alt="Playwright"></a>
<a href="https://bundlephobia.com/package/playwright-reselect"><img src="https://img.shields.io/bundlephobia/minzip/playwright-reselect" alt="Bundle size"></a>
</p>
<p align="center">
<img
src="https://raw.githubusercontent.com/marcflavius/playwright-reselect/main/dist/img/playwrightreselect.jpg"
alt="playwright-reselect logo"
width="800"
/>
</p>
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](#installation)
- [Quick Start](#quick-start)
- [API](#api)
- [Core concepts](#core-concepts)
- [Node methods](#node-methods)
- [Quick Tour](#quick-tour)
- [Building the Tree — Tips](#building-the-tree--tips)
- [UI Tips](#ui-tips)
- [Examples](#examples)
- [Get a node](#get-a-node)
- [Debug current node](#debug-current-node)
- [Chained expectations](#chained-expectations)
- [Custom getter](#custom-getter)
- [Reusable list with custom getter](#reusable-list-with-custom-getter)
- [Store a node in a variable](#store-a-node-in-a-variable-and-get-multiple-subparts-from-the-stored-variable)
- [Skip to Alias — Quick navigation ✨ NEW](#skip-to-alias--quick-navigation-to-deeply-nested-nodes--new)
- [Advanced](#advanced)
- [Testing Layout Setup](#testing-layout-setup)
- [Testing Dynamic Layout](#testing-dynamic-layout)
- [Reuse Tree Branches Across UIs](#reuse-tree-branches-across-uis)
- [Security](#security)
- [License](#license)
## Installation
Install from npm:
```bash
npm install --save-dev playwright-reselect
# or
pnpm add -D playwright-reselect
```
You 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:
```ts
// 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');
},
},
},
},
});
```
```ts
// 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 updates `ctx.locator`. Build functions don't need an explicit return statement.
- `reselectTree(treeDescription)(page)`: Provide the tree description to `reselectTree` to get a function that accepts a Playwright `page` and returns an object with root node accessors. Calling a node runs its `build` and 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 accept `ctx` first and return a `Locator`). This is useful if you need to select multiple parts of a node.
<p align="center">
<img
src="https://raw.githubusercontent.com/marcflavius/playwright-reselect/main/dist/img/autocomplete.png"
alt="Playwright API autocomplete after .get()"
width="600"
/>
</p>
- `node.debug()` — print a pretty HTML snapshot of the matched element.
- `node.expectChain()` — chainable async matchers mirroring Playwright `expect`.
- `node.inspect()` — logs the locator selector chain and returns the node for chaining.
- `node.<child>()` — move into a child node defined in `children`.
- `node.skipToAlias()` — returns an object containing all aliased descendant nodes, allowing direct navigation to deeply nested nodes without traversing the full path.
### Quick Tour
```ts
// 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 getter
```
### Building 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 `custom` getters for repeated or parameterized selections instead of inline locators.
- Return early from `build` with 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-testid` when 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
```ts
// 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
```ts
// 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
```ts
// 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 2
```
### Custom getter
```ts
// 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
```ts
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.
```ts
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:
```ts
...
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:
```text
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:
```ts
// 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:
```text
app
├── header
│ ├── topSection
│ │ └── headerLogo (alias: 'headerLogo')
│ └── bottomSection
│ └── menuBtn (alias: 'menuBtn')
└── content
└── navigation
└── search (alias: 'search')
```
**Scoping behavior:**
```ts
// 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 content
```
#### TypeScript 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:
```ts
// 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.
```ts
// 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)
```ts
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 `beforeEach` to 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.
```ts
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()` or `page.waitForResponse()` with assertions.
- Use `.debug()` to print the matched element HTML to quickly craft robust selectors.
```ts
// 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 <u7081838225@gmail.com>
Love making things simple. Rocket science! 🚀