@lautarodev/playwright-components
v0.0.4
Published
Declare a UI component once — its locator, typed elements, and actions — then `mount()` it into a fully-typed Playwright test handle.
Downloads
787
Readme
playwright-components
Declare a UI component once — its locator, typed elements, and actions — then
mount() it into a fully-typed Playwright test handle.
const loginPage = mount(page, LoginPage);
await loginPage.form.login('[email protected]', 'secret');
await expect(page).toHaveURL('/dashboard');
await expect(loginPage.form.email).toBeVisible(); // primitives are real LocatorsThat LoginPage is the end of the road — a page composed of nested components.
This README builds up to it from the smallest possible component.
Install
npm install -D @lautarodev/playwright-components@playwright/test >= 1.61.0 is a declared peer dependency. npm 7+ installs it
automatically. With yarn or npm 6, install it explicitly:
yarn add -D @lautarodev/playwright-components @playwright/testMental model
Three ideas carry the whole library:
- Declare a component with
Component({ locator, template, actions })—templatenames the elements,actionsare high-level methods over them. - Mount it with
mount(page, decl)to get a typed handle. - On that handle, primitives are real
Locators —expect(handle.field)works with no unwrapping, and actions are callable methods.
The rest is composition: components nest inside components, all the way up to a full page.
Your first component
The smallest useful component: one element, no custom actions. A primitive
wraps a Locator and adds semantic methods (Button gets .click()), but the
result still is a Locator.
import { test, expect } from '@playwright/test';
import { Component, mount, Button } from '@lautarodev/playwright-components';
const SubmitBar = Component({
locator: (p) => p.locator('.submit-bar'),
template: (root) => ({
submit: Button(root.locator('button[type=submit]')),
}),
});
test('submit button works', async ({ page }) => {
await page.goto('/');
const bar = mount(page, SubmitBar);
await expect(bar.submit).toBeVisible(); // submit is a real Locator
await bar.submit.click(); // primitive action method
});Components with actions
Add an actions map to bundle several element interactions behind one method.
actions receives the template, so each action drives the named elements.
import { test, expect } from '@playwright/test';
import { Component, mount, Input, Button } from '@lautarodev/playwright-components';
const SearchBox = Component({
locator: (p) => p.locator('.search'),
template: (root) => ({
input: Input(root.locator('input')),
submit: Button(root.locator('button[type=submit]')),
}),
actions: (template) => ({
search: async (query: string) => {
await template.input.fill(query);
await template.submit.click();
},
}),
});
test('search returns results', async ({ page }) => {
await page.goto('/');
const box = mount(page, SearchBox);
await box.search('playwright');
await expect(page).toHaveURL(/\?q=playwright/);
await expect(box.input).toBeVisible(); // input is still a Locator
});Effects & preconditions
An action can declare a precondition (runs before the action) and an
effects entry (runs after it). Use effects to wait for the outcome —
navigation, a toast, a state change — so tests don't sprinkle waitFor* calls.
import { Component, Input, Button } from '@lautarodev/playwright-components';
const LoginForm = Component({
locator: (p) => p.locator('.login-form'),
template: (root) => ({
email: Input(root.locator('input[name=email]')),
password: Input(root.locator('input[name=password]')),
submit: Button(root.locator('button[type=submit]')),
}),
actions: (template) => ({
login: async (email: string, password: string) => {
await template.email.fill(email);
await template.password.fill(password);
await template.submit.click();
},
}),
precondition: {
login: (template) => {
// runs before login() — assert the form is ready
void template.submit;
},
},
effects: {
login: (page) => page.waitForURL('**/dashboard'),
},
});The precondition is template-based (it sees the elements); the effect is
page-based (it sees the whole page). Calling form.login(...) now waits for
/dashboard automatically.
Skipping the effect
Pass { wait: false } as a trailing argument to skip the post-effect only — the
precondition always runs. Useful when a parent action already handles the
navigation, or when you need to assert mid-flight.
await form.login('[email protected]', 'secret', { wait: false }); // effect skippedThis is the WaitOpts pattern: every leaf action accepts an optional trailing
{ wait?: boolean }.
Composition — mirror pattern
Components nest by mirroring a child into both template and actions of
the parent under the same key. The mounted handle merges the two sides, so
handle.child.someElement (a Locator) and handle.child.someAction() are both
available.
Extract the password field from the form above into its own component, then compose it back:
import { Component, mount, Input, Button, Toggle } from '@lautarodev/playwright-components';
// Child — a reusable password field with show/hide
const PasswordField = Component({
locator: (p) => p.locator('.password-field'),
template: (root) => ({
input: Input(root.locator('input')),
toggle: Toggle(root.locator('.show-password')),
}),
actions: (template) => ({
type: async (value: string) => {
await template.input.fill(value);
},
reveal: async () => {
await template.toggle.check();
},
}),
});
// Parent — composes PasswordField under the key `password`
const LoginForm = Component({
locator: (p) => p.locator('.login-form'),
template: (root) => ({
email: Input(root.locator('input[name=email]')),
password: PasswordField.template!(root.locator('.password-field')), // mirror: element side
submit: Button(root.locator('button[type=submit]')),
}),
actions: (template) => ({
password: PasswordField.actions!(template.password), // mirror: action side
login: async (email: string, password: string) => {
await template.email.fill(email);
await template.password.input.fill(password); // child elements reachable in actions
await template.submit.click();
},
}),
});After mounting, the password key exposes both sides:
const form = mount(page, LoginForm);
// element side — password.input is a real Locator
await expect(form.password.input).toBeVisible();
// action side — password.type() and password.reveal() are callable
await form.password.type('secret');
await form.password.reveal();
// parent action — login() uses both email and password internally
await form.login('[email protected]', 'secret');Page objects
A page is just the top-level component: its locator resolves the page root,
and it mirrors one or more child components. This is the deepest nesting — a
page composing a Navbar and the LoginForm from above.
import { Component, mount, Link, Button } from '@lautarodev/playwright-components';
const Navbar = Component({
locator: (p) => p.locator('nav'),
template: (root) => ({
home: Link(root.locator('a[href="/"]')),
logout: Button(root.locator('.logout')),
}),
actions: (template) => ({
goHome: async () => {
await template.home.click();
},
}),
});
const LoginPage = Component({
locator: (p) => p.locator('body'), // page root
template: (root) => ({
navbar: Navbar.template!(root.locator('nav')),
form: LoginForm.template!(root.locator('.login-form')),
}),
actions: (template) => ({
navbar: Navbar.actions!(template.navbar),
form: LoginForm.actions!(template.form),
}),
});The mounted page reads exactly like the teaser at the top:
const loginPage = mount(page, LoginPage);
await loginPage.form.login('[email protected]', 'secret');
await loginPage.navbar.goHome();
await expect(loginPage.form.email).toBeVisible();Page objects with navigation
Page() is the top-level counterpart to Component(). It accepts the same
fields plus a url string — and the mounted handle gains an explicit .goto()
method that navigates to that URL.
import { Page, Component, mount, Link, Button } from '@lautarodev/playwright-components';
const LoginPage = Page({
name: 'LoginPage',
url: '/login',
locator: (p) => p.locator('body'),
template: (root) => ({
navbar: Navbar.template!(root.locator('nav')),
form: LoginForm.template!(root.locator('.login-form')),
}),
actions: (template) => ({
navbar: Navbar.actions!(template.navbar),
form: LoginForm.actions!(template.form),
}),
});Mounting works identically, but the handle gains .goto():
const loginPage = mount(page, LoginPage);
await loginPage.goto(); // navigates to '/login'
await loginPage.form.login('[email protected]', 'secret');
await loginPage.navbar.goHome();
await expect(loginPage.form.email).toBeVisible();.goto() is explicit — it fires only when you call it, never automatically
on mount(). Call it once in your fixture setup; the test body stays
navigation-free.
Pages cannot be children
Page declarations do not expose .template or .actions, so they cannot be
composed as mirror children inside another component's template. Only
Component declarations can be nested. TypeScript will not surface a method to
embed a Page as a child — the constraint is enforced at the type level.
Fixture wiring with Page
Because the URL lives on the declaration, fixture setup shrinks to a one-liner:
// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { mount } from '@lautarodev/playwright-components';
import type { MountedPage } from '@lautarodev/playwright-components';
import { LoginPage } from './declarations';
export const test = base.extend<{ loginPage: MountedPage<typeof LoginPage> }>({
loginPage: async ({ page }, use) => {
const lp = mount(page, LoginPage);
await lp.goto(); // URL is on the declaration, not the fixture
await use(lp);
},
});
export { expect };// login.spec.ts
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
await loginPage.form.login('[email protected]', 'secret');
await expect(page).toHaveURL('/dashboard');
});Step tracing for goto
.goto() emits a test.step like every other action method. With
name: 'LoginPage' the trace label is LoginPage.goto.
Writing tests
Fixture wiring stays in your test code — the library only declares and mounts
components. Use Playwright's own test.extend, then mount(page, decl) and
pass the handle to use.
When the declaration is a Page, call .goto() inside the fixture to navigate.
When it is a plain Component, handle navigation yourself before calling use.
// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { mount } from '@lautarodev/playwright-components';
import type { MountedPage, Mounted } from '@lautarodev/playwright-components';
import { LoginPage } from './declarations'; // declared with Page()
export const test = base.extend<{ loginPage: MountedPage<typeof LoginPage> }>({
loginPage: async ({ page }, use) => {
const lp = mount(page, LoginPage);
await lp.goto(); // URL lives on the declaration
await use(lp);
},
});
export { expect };// login.spec.ts
import { test, expect } from './fixtures';
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
await loginPage.form.login('[email protected]', 'secret');
await expect(page).toHaveURL('/dashboard');
});
test('password reveal toggle works', async ({ loginPage }) => {
await loginPage.form.password.reveal();
await expect(loginPage.form.password.input).toHaveAttribute('type', 'text');
});page stays in the fixture closure — it is NOT on the mounted handle. Need
page in a test? Pull it from Playwright's own fixtures, as above:
async ({ loginPage, page }) => ….
Reference
Primitives
Each primitive wraps a Locator with semantic action methods. The result is
a real Locator, so expect(handle.field) works with no unwrapping.
| Primitive | Method(s) | Notes |
|-----------|-----------|-------|
| Button | .click(options?) | — |
| Link | .click(options?) | — |
| Input | .fill(value, options?) .clear(options?) | Covers <input> and <textarea> |
| Dropdown | .select(values, options?) | Native <select> — wraps selectOption |
| Combobox | .choose(label, options?) | Custom/portaled dropdown — page-wide option match |
| RadioButton | .check(options?) | One-way only — no uncheck |
| Toggle | .check(options?) .uncheck(options?) | Checkbox or switch |
| Image | — | Assert-only; the Locator itself is the surface |
| Label | — | Assert-only; the Locator itself is the surface |
options on each method passes through to the underlying Playwright method.
Step tracing
Every leaf action is automatically wrapped in test.step(label, fn) and appears
in the Playwright trace viewer and HTML report as a named, collapsible step. No
configuration is required.
Step label format
- With a
nameon the declaration:${name}.${key-path}— e.g.LoginForm.password.type - Without
name: dot-joined key path — e.g.password.type - For
.goto()on aPage:${name}.goto— e.g.LoginPage.goto
const LoginForm = Component({
name: 'LoginForm', // optional — improves label readability in traces
locator: (p) => p.locator('.login-form'),
// ...
});
// Trace label: "LoginForm.login"
await form.login('[email protected]', 'secret');
// Trace label: "LoginForm.password.type" (nested mirror key)
await form.password.type('secret');A named child component embedded directly in a parent template nests under the
parent: a child with name: 'Nav' at key header under App labels its actions
App.Nav.* (the name replaces the key as the segment, the parent prefix is kept).
Opt out per mount
Pass { steps: false } as the third argument to disable step wrapping for a
specific mount. Actions continue to execute identically.
import type { MountOptions } from '@lautarodev/playwright-components';
const opts: MountOptions = { steps: false };
const form = mount(page, LoginForm, opts);
await form.login('[email protected]', 'secret'); // no test.step emittedStep tracing has no effect outside a Playwright runner (build scripts, type-only imports) — the library degrades silently to a direct call.
Collision rule
A key cannot be both a leaf template element and an action function at the
same level. This is enforced at compile time (NoCollision resolves to never)
and thrown at mount time.
// Bad — `submit` is both a Button element and an action function
const Bad = Component({
locator: (p) => p.locator('.x'),
template: (root) => ({
submit: Button(root.locator('button')), // leaf element key
}),
actions: () => ({
submit: async () => {}, // same key → collision
}),
});
// TypeScript: typeof Bad is `never`
// Runtime: mount(page, Bad) throws
// Good — action key differs from the element key
const Good = Component({
locator: (p) => p.locator('.x'),
template: (root) => ({
submitBtn: Button(root.locator('button')),
}),
actions: (template) => ({
submit: async () => { await template.submitBtn.click(); },
}),
});A mirror child (whose action value is a bag of functions, not a bare function) is never a collision — that is how composition shares a key by design.
Type safety
mount(page, decl) is overloaded by declaration kind:
| Declaration | mount() return type | Has .goto()? |
|-------------|-----------------------|----------------|
| Component(...) | Mounted<typeof decl> | No |
| Page(...) | MountedPage<typeof decl> | Yes |
Both types are fully derived from the declaration — no casting needed. If the declaration changes, the handle type updates automatically.
import type { Mounted, MountedPage } from '@lautarodev/playwright-components';
// Component handle
type LoginFormHandle = Mounted<typeof LoginForm>;
// loginForm.email → InputLocator (Locator + .fill() + .clear())
// loginForm.password.input → InputLocator
// loginForm.password.type → (value: string, opts?: WaitOpts) => Promise<void>
// loginForm.login → (email: string, password: string, opts?: WaitOpts) => Promise<void>
// Page handle
type LoginPageHandle = MountedPage<typeof LoginPage>;
// loginPage.goto → () => Promise<void>
// loginPage.form.login → (email: string, password: string, opts?: WaitOpts) => Promise<void>
// loginPage.navbar.goHome → (opts?: WaitOpts) => Promise<void>Page factory
| Export | Kind | Description |
|--------|------|-------------|
| Page | function | Declares a top-level page component with a url |
| PageDecl | type | The branded type returned by Page() |
| MountedPage | type | The handle type for a mounted Page — includes .goto() |
| isPageDecl | function | Runtime guard: true when value is a PageDecl |
