TTACart Page Object Creator
Turn a live TTACart page into a Page Object class that looks like it was
hand-written by the same author as the rest of src/pages/. The whole point is
consistency — a new page class should be indistinguishable in style from
LoginPage.ts, CartPage.ts, InventoryPage.ts, and ItemDetailPage.ts.
Why drive the live page instead of guessing selectors
TTACart marks every meaningful element with a data-test attribute (e.g.
[data-test="checkout"]). Those are the stable selectors the framework uses.
You cannot reliably invent them — you must read them off the rendered DOM. The
accessibility snapshot alone is not enough: it shows roles and text but
hides data-test attributes. So the key move is a browser_evaluate call
that dumps every [data-test] element. That is the source of truth.
Inputs you need from the user
- Target URL — the page to model, e.g.
https://app.thetestingacademy.com/playwright/ttacart/checkout-step-one.html - The flow to reach it — most TTACart pages require login (and often items
in the cart) first. Typical flow: login (
standard_user/tta_secret) → inventory → add an item → navigate to the target. If the user doesn't spell it out, assume that standard flow and tell them what you assumed. - Class name (optional) — defaults to the page, e.g. checkout-step-one →
CheckoutStepOnePage.
If the URL or flow is missing, ask — don't guess a URL.
Workflow
1. Drive the browser to the target page (Playwright MCP)
Use the mcp__playwright__browser_* tools. Standard path:
browser_navigateto the login page (.../playwright/ttacart/index.html).browser_snapshotto get element refs, thenbrowser_fill_form/browser_type+browser_clickto log in asstandard_user/tta_secret.- Walk the rest of the flow (open inventory, add an item, etc.). Prefer
browser_navigateto a known.htmlURL when you just need to land on a page; use clicks when the flow's state matters (e.g. adding to cart). - Land on the target URL.
2. Capture the real selectors
On the target page, run BOTH:
browser_snapshot— gives you the structure, headings, and link targets.browser_evaluatewith this function to dump thedata-testmap (this is the part that actually drives the locator fields):
() => {
const out = [];
document.querySelectorAll('[data-test]').forEach(el => {
out.push({
tag: el.tagName.toLowerCase(),
dataTest: el.getAttribute('data-test'),
href: el.getAttribute('href') || null,
text: (el.textContent || '').trim().slice(0, 50),
});
});
return out;
}
3. Map selectors → locator fields
- One
private readonlyLocator field per meaningful element. Name fields by intent (checkoutButton,itemRows,firstNameInput), not by selector. - Lists (repeated rows) → a single Locator that matches all of them
(
page.locator('[data-test="inventory-item"]')), exposed via acount()orgetAllTexts()method. - Per-id elements (e.g.
remove-tta-bike-light,add-to-cart-<id>) → a private builder method, not a field:private removeBtn(id: string): Locator { return this.page.locator(`[data-test="remove-${id}"]`); } - Ignore boilerplate shared chrome (sidebar/menu/footer/social links) unless the flow actually needs it — the existing pages don't model it.
4. Generate <Name>Page.ts
Write to src/pages/<Name>Page.ts using the template below. Match indentation
(4 spaces) and the JSDoc-usage-example header style of the existing files.
5. Wire it into the barrel
Add export { <Name>Page } from './<Name>Page'; to src/pages/index.ts. If a
commented-out placeholder for it exists, uncomment that.
6. Verify (required — project rule)
Run both and confirm exit 0:
npm run typecheck
npm run lint
Fix anything they flag (a common one: a declared locator you never use → either
use it in assertLoaded() or drop it). Report the results honestly.
The class template
This is the exact shape every TTACart page follows. Keep it.
import { expect, Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* TTACart <human name> page.
*
* const page = new <Name>Page(page);
* await page.open();
* await page.<primaryAction>();
*/
export class <Name>Page extends BasePage {
static readonly PATH = '/playwright/ttacart/<file>.html';
private readonly title: Locator;
// ...one private readonly Locator per meaningful element
constructor(page: Page) {
super(page, '<Name>Page');
this.title = page.locator('[data-test="title"]');
// ...assign each locator with a [data-test="..."] selector
}
async open(): Promise<void> {
await this.goto(<Name>Page.PATH);
await this.assertLoaded();
}
async assertLoaded(): Promise<void> {
// Pin the page: assert URL and/or a stable heading.
await expect(this.title).toContainText('<expected heading>');
}
// ...intent-named action + read methods, all going through `this.el`
}
Non-negotiable conventions (why each matters)
import { BasePage } from './BasePage';— the project is CommonJS (no"type": "module", tsconfigmodule: Node16). Relative/alias imports are extensionless — do NOT add.js. Package imports (@playwright/test) are bare too.extends BasePage+super(page, '<Name>Page')— gives youthis.page,this.el, and a scopedthis.logfor free. The scope string is the class name so logs read[<Name>Page] ….- Actions go through
this.el(theUtilElementLocatorwrapper), not rawlocator.click(). This centralises timeouts + per-action debug logging. Available helpers:click,doubleClick,rightClick,hover,fill,type,clear,pressSequentially,getText,getInnerText,getAllTexts,getAttr,getValue,count,isVisible,isEnabled,isChecked,waitForVisible,waitForHidden,waitForPageLoad,selectByText,selectByValue,selectByIndex. static readonly PATH— the page's relative URL.open()alwaysgoto(PATH)thenassertLoaded().assertLoaded()— a cheap guard so a misnavigation fails fast with a clear message instead of a confusing downstream error. Assert a heading and/or URL.- Navigation methods that leave the page (
checkout(),continueShopping()) end withawait this.page.waitForLoadState('domcontentloaded'). private readonlylocator fields — assigned once in the constructor, never reassigned.
Worked example — CartPage
Flow: login → inventory → add an item → open cart.html. The data-test dump
yielded title, inventory-item (rows), inventory-item-name,
continue-shopping, checkout, and per-id remove-<id>. That produced:
import { expect, Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
export class CartPage extends BasePage {
static readonly PATH = '/playwright/ttacart/cart.html';
private readonly title: Locator;
private readonly itemRows: Locator;
private readonly itemNames: Locator;
private readonly continueShoppingLink: Locator;
private readonly checkoutButton: Locator;
constructor(page: Page) {
super(page, 'CartPage');
this.title = page.locator('[data-test="title"]');
this.itemRows = page.locator('[data-test="inventory-item"]');
this.itemNames = page.locator('[data-test="inventory-item-name"]');
this.continueShoppingLink = page.locator('[data-test="continue-shopping"]');
this.checkoutButton = page.locator('[data-test="checkout"]');
}
async open(): Promise<void> {
await this.goto(CartPage.PATH);
await this.assertLoaded();
}
async assertLoaded(): Promise<void> {
await expect(this.title).toContainText('Your Cart');
}
async itemNamesList(): Promise<string[]> {
return this.el.getAllTexts(this.itemNames);
}
async rowCount(): Promise<number> {
return this.itemRows.count();
}
async remove(id: string): Promise<void> {
await this.el.click(this.page.locator(`[data-test="remove-${id}"]`));
}
async continueShopping(): Promise<void> {
await this.el.click(this.continueShoppingLink);
await this.page.waitForLoadState('domcontentloaded');
}
async checkout(): Promise<void> {
await this.el.click(this.checkoutButton);
await this.page.waitForLoadState('domcontentloaded');
}
}
Final report to the user
State: the page modelled, the file path, which selectors were captured, the barrel update, and the typecheck/lint results (exit codes). If you assumed a flow or skipped any chrome elements, say so.