POMWright
POMWright is a complementary test framework for Playwright written in TypeScript.
Install / Use
/learn @DyHex/POMWrightREADME
POMWright
POMWright is a TypeScript companion framework for Playwright focused on Page Object Model workflows.
It provides automatic locator chaining via a LocatorRegistry, a Session Storage API, a log fixture for report attachments, and a test.step decorator — all of which can be used independently either through a functional approachs or as part of your custom POMs or quickly create new standardized POMS by extending a class with the provided the abstract PageObject class. PageObject also adds navigation helpers and URL typing which supports multiple base URLs and dynamic RegExp paths.
Version status: v2 runtime only
POMWright now ships a v2-only runtime API centered on PageObject, LocatorRegistry, and standalone helpers.
Legacy v1 and migration documentation is retained under:
docs/v1docs/v1-to-v2-migration
Why use POMWright instead of vanilla Playwright POM?
Playwright’s official best practices emphasize locator chaining for clarity and resilience. In practice, manually writing and maintaining those chains becomes tedious and fragile as pages grow. POMWright’s solution is the LocatorRegistry, which automatically builds locator chains from single locator definitions tied to unique paths. You register one locator per path (with Playwright‑like syntax), and POMWright composes the full chain for you.
This gives you the same Playwright primitives you already use — but with dramatically simpler maintenance, better structure, and safer refactors across small and large projects.
Chain everything
POMWright’s default behavior is to resolve locators by chaining all segments in a path. That brings two major benefits:
-
Robustness through scope‑aware uniqueness: Chaining narrows selectors through the intended DOM structure, producing more robust locators that are less brittle to UI changes and therefore less prone to flake. A “Change” button tied to a password field under a “Credentials” region resolves differently than a “Change” button tied to an email field under a “Contact info” region. If an identical button is added elsewhere, existing tests keep working because their paths remain scoped. If the button is mistakenly added under the wrong section, tests will fail and reveal the mistake.
-
Implicit DOM structure validation: Because chains traverse the DOM hierarchy, locator resolution validates that the UI is still arranged as expected — as strictly or loosely as you choose. You don’t need to map the entire DOM; just chain the “structural anchors” that matter. Often, user‑visible elements are good enough.
Highlights
- Centralized locator definitions with automatic chaining — one path defines an entire selector chain across the app, across all Playwright locator types.
- Safer refactors — update a locator once and all usages update automatically (including full chains).
- Typed paths and sub‑paths with contextual autocomplete and compile‑time validation, making structural updates as simple as find‑and‑replace.
- Explicit control of chain depth — choose terminal resolution (getLocator) or full chained resolution (
getNestedLocator) at the call site. - Non‑mutating query overrides — handle edge cases, variations, and iteration without changing base definitions (
getLocatorSchema(...).update/replace/remove/filter/nth/describe). - Reusable locator seeds + typed path reuse — share locator patterns or clone existing definitions across contexts without duplication.
- Optional helpers — locator registry, session storage, logging, and step decorator can be adopted independently.
- Minimal base class —
PageObjectprovides URL + navigation + registry + session storage, leaving everything else to your own composition.
Example: vanilla Playwright POM vs POMWright PageObject
Vanilla Playwright POM (typical)
import { test, type Locator, type Page } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly main: Locator;
readonly loginForm: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.main = page.locator("main");
this.loginForm = this.main.getByRole("form", { name: "Login" });
this.usernameInput = this.loginForm.getByLabel("Username");
this.passwordInput = this.loginForm.getByLabel("Password");
this.submitButton = this.main.getByRole("button", { name: "Login" });
}
async goto() {
await test.step("LoginPage.goto", async () => {
await this.page.goto("/login");
});
}
async login(username: string, password: string) {
await test.step("LoginPage.login", async () => {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
});
}
}
Vanilla Playwright fixture
import { test as base } from "@playwright/test";
import { LoginPage } from "./login.page";
type Fixtures = { loginPage: LoginPage };
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
Vanilla Playwright Test
import { test } from "./fixtures";
test("login flow", async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login("alice", "secret");
});
POMWright PageObject (v2)
import { type Page } from "@playwright/test";
import { PageObject, step } from "pomwright";
type Paths =
| "main"
| "main.form@login"
| "main.form@login.input@username"
| "main.form@login.input@password"
| "main.button@login";
export class LoginPage extends PageObject<Paths> {
constructor(page: Page) {
super(page, "https://example.com", "/login");
}
protected defineLocators(): void {
this.add("main").locator("main");
this.add("main.form@login").getByRole("form", { name: "Login" });
this.add("main.form@login.input@username").getByLabel("Username");
this.add("main.form@login.input@password").getByLabel("Password");
this.add("main.button@login").getByRole("button", { name: "Login" });
}
protected pageActionsToPerformAfterNavigation() {
return [
async () => {
await this.getNestedLocator("main.form@login").waitFor({ state: "visible" });
},
];
}
@step()
async login(username: string, password: string) {
await this.getNestedLocator("main.form@login.input@username").fill(username);
await this.getNestedLocator("main.form@login.input@password").fill(password);
await this.getNestedLocator("main.button@login").click();
}
}
Tip: If a Page Object grows large with many Paths and this.add(...) calls, move the locator definitions into a companion file (e.g., login.page.ts + login.locators.ts) to keep the class focused on behavior while keeping the registry definitions centralized and reusable. Simmilarily you can move all Paths and add calls for locator definitions common to all POMs for a given domain into a common.locators.ts file to share across your POMs.
// login.locators.ts
import type { LocatorRegistry } from "pomwright";
import { Paths as Common, defineLocators as addCommon } from "../common.locators"; // errors, dialogs, navbar, main, etc.
export type Paths =
| Common
| "main.form@login"
| "main.form@login.input@username"
| "main.form@login.input@password"
| "main.button@login";
export function defineLocators(registry: LocatorRegistry<Paths>) {
addCommon(registry);
registry.add("main.form@login").getByRole("form", { name: "Login" });
registry.add("main.form@login.input@username").getByLabel("Username");
registry.add("main.form@login.input@password").getByLabel("Password");
registry.add("main.button@login").getByRole("button", { name: "Login" });
}
// login.page.ts
import { type Page } from "@playwright/test";
import { PageObject, step } from "pomwright";
import { type Paths, defineLocators } from "./login.locators.ts";
export class LoginPage extends PageObject<Paths> {
constructor(page: Page) {
super(page, "https://example.com", "/login");
}
protected defineLocators(): void {
defineLocators(this.locatorRegistry);
}
protected pageActionsToPerformAfterNavigation() {
return [
async () => {
await this.getNestedLocator("common.nav.logo").waitFor({ state: "visible" });
await this.getNestedLocator("main.form@login").waitFor({ state: "visible" });
},
];
}
@step()
async login(username: string, password: string) {
await this.getNestedLocator("main.form@login.input@username").fill(username);
await this.getNestedLocator("main.form@login.input@password").fill(password);
await this.getNestedLocator("main.button@login").click();
}
}
POMWright fixtures
import { test as base } from "@playwright/test";
import { PlaywrightReportLogger, type LogEntry, type LogLevel } from "pomwright";
import { LoginPage } from "./login.page";
type Fixtures = {
loginPage: LoginPage,
log: PlaywrightReportLogger
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
log: async ({}, use, testInfo) => { // or just import { test as base } from "pomwright";
const sharedLogEntry: LogEntry[]
