The Testing Academy · Playwright Practice

Advance Playwright Framework,
Explained Command by Command

One real end-to-end checkout spec, one advanced Playwright + TypeScript framework, and a playable diagram that shows exactly what every command does, from the test line through the page objects, fixtures and utils, into Playwright and the browser, and back with a screenshot for the report. Then a full tour of every file that makes it run.

Playable per-command diagramReal e2e specEvery file explainedPOM + fixtures + utils

Play the spec, command by command

This is the actual e2e-checkout.spec.ts. Each chip is one command from the test. Press Play and the map lights up layer by layer, colour-coded by what each step touches, with a plain-English explanation at every hop.

Spec command
Step 0 / 0
YOUR FRAMEWORK PLAYWRIGHT BROWSER e2e-checkout.specthe test linetest.describe test-base fixturehands over a POMtest.extend Page ObjectLoginPage, CartPagedata-test locators UtilElementLocatorel.click / el.fillone wrapper DataGeneratorFaker customerfirstName, zip logger (Winston)scoped per POM visualSteptest.step +screenshot CustomReporterTTA HTML + Allureone img per step playwright.config.tsbaseURL by env, projects api+chromium, trace on-first-retry, video on Client SDK@playwright/testlocators + expect DriverNode.js serverauto-wait engine ProtocolCDP (Chromium)per command Browser processChromium contextcookies + storage Rendererlayout + JS engineper tab DOMTTACart pagedata-test nodes Navigationgoto + load state action CDP data in screenshot

Every command in e2e-checkout.spec.ts is a chip. Pick one and press Play to watch it travel from the spec line, through the fixture, page object and util layers, into Playwright and the browser, and back with a screenshot for the report.

The full walkthrough
  1. The framework at a glance
  2. The e2e checkout spec, line by line
  3. playwright.config.ts, decoded
  4. Fixtures and dependency injection
  5. BasePage and the Page Object layer
  6. UtilElementLocator: one wrapper to rule them
  7. Test data, config, and logging
  8. visualStep, the TTA reporter, and Allure
  9. The API layer, CI, and running it yourself
Note: claude-opus-4-8 (the safety classifier) was unavailable when reviewing this subagent's work. Please carefully verify the subagent's actions and output before acting on them. I have everything I need. I've read the README, the build spec (`docs/architecture.md`), the full `src/` tree, both configs, the fixtures, every relevant Page Object, the utils, the CI workflow, and the flagship spec. Writing the section now. Note one code-vs-doc fact I'll surface faithfully: `docs/architecture.md` is a build spec that called for a separate `src/modules/` business-logic layer, but the shipped code folds those actions onto the Page Object methods (no `modules/` dir exists). And the real spine path is `src/tests/e2e/e2e-checkout.spec.ts` with `FIRST_ITEM_ID = 'test-allthethings-tshirt-red'`.

1. The framework at a glance

AdvancePlaywrightFramework1x is a playwright typescript framework that The Testing Academy uses to teach one idea properly: a real suite is layered, not a pile of scripts. Open any spec in it and you will not find a single page.locator() call or a hard-coded URL. What you find instead is a small vocabulary of page objects, handed to the test as playwright fixtures, orchestrated by a spec that reads like a checklist. Every later section of this walkthrough pulls one thread out of that structure, so this first section maps the whole cloth before we start cutting.

The three layers that carry the weight

The backbone of this advanced playwright framework is a strict separation across three layers. Layer one is locators: each page declares its selectors as private readonly Locator fields, one place per screen. Layer two is actions: intent-revealing methods on that same page object (loginAs, addToCart, fillGuest, finish, assertOrderComplete) that turn clicks and fills into business language. Layer three is the spec, which only ever calls those methods. This is a disciplined take on the page object model: playwright gives you the primitives, and the framework decides that tests never touch primitives directly.

export class LoginPage extends BasePage {
    static readonly PATH = '/playwright/ttacart/index.html';

    private readonly usernameInput: Locator;   // layer 1: locators
    private readonly loginButton: Locator;

    constructor(page: Page) {
        super(page, 'LoginPage');        // scope = subclass name (for logs + el wrapper)
        this.usernameInput = page.locator('[data-test="username"]');
        this.loginButton  = page.locator('[data-test="login-button"]');
    }

    async loginAs(username: string, password: string): Promise<void> {  // layer 2: action
        this.log.info(`loginAs ${username}`);
        await this.el.fill(this.usernameInput, username);
        await this.el.click(this.loginButton);
    }
}

Every page extends BasePage, an intentionally thin abstract parent that wires the three things each page needs: the Playwright page handle, an el action wrapper (UtilElementLocator), and a scoped log named after the subclass. Base does not pre-build any locators. It only supplies a goto() helper that respects baseURL, so subclasses stay focused on their own screen. That is the entire inheritance contract: page handle, element wrapper, logger, navigate.

Doc versus code. docs/architecture.md is the original build brief and calls for a separate src/modules/ business-logic layer between pages and specs. The shipped framework folds that layer directly onto the page-object methods, so no modules/ directory exists. When the doc and the code disagree, trust the code: the live three layers are locators, page-object methods, and specs.

A map of the repository

The tree is small and every folder has one job. This is where each layer of the playwright framework physically lives:

Folder / fileWhat lives there
src/pages/Page Objects. Locator fields plus action methods, all extending BasePage, re-exported from index.ts.
src/fixtures/test-base.ts extends Playwright test with one fixture per page object; booker.fixture.ts does the same for API.
src/utils/The engine room: logger.ts (Winston), UtilElementLocator.ts, DataGenerator.ts (Faker), visualStep.ts, CustomReporter.ts, ApiHelper.ts, schemaValidator.ts.
src/config/credentials.ts reads .env (STANDARD_USER, TTA_SECRET) with public-demo fallbacks.
src/api/Typed API clients (BookingApi.ts) for the Restful Booker examples.
src/testdata/Payload factories and Draft-07 JSON Schemas under schemas/.
src/tests/e2e/The UI flow: e2e-checkout.spec.ts, the flagship test.
src/tests/apiTests/Layered API specs (raw request, helper, typed client, JSONPath, Ajv) run by the dedicated api project.
rootplaywright.config.ts (projects, reporters, env baseURL), tsconfig.json (path aliases), .github/workflows/ (CI).

TTACart and the golden path

The app under test is TTACart, a SauceDemo-style storefront hosted at app.thetestingacademy.com/playwright/ttacart. It is a set of static HTML pages backed by localStorage, and crucially every meaningful element carries a stable data-test attribute (data-test="username", data-test="add-to-cart-...", data-test="finish"). That is why the page objects lean on attribute selectors rather than brittle text or CSS class chains: the app was built to be automated. The suite drives it as a single standard user, standard_user / tta_secret, pulled from @config/credentials.

The "golden path" is the happy-case purchase flow, and it is the shape you should hold in your head for the rest of the article. Each screen is one page object with one primary action:

StepPage (path)Page ObjectKey action
1. Log inindex.htmlLoginPageloginAs(user, pass)
2. Inventoryinventory.htmlInventoryPageaddToCart(id)
3. Cartcart.htmlCartPagerowCount(), checkout()
4. Checkout onecheckout-step-one.htmlCheckoutStepOnePagefillGuest(customer), continue()
5. Checkout twocheckout-step-two.htmlCheckoutStepTwoPagefinish()
6. Completecheckout-complete.htmlCheckoutCompletePageassertOrderComplete()

What makes it advanced, not a beginner script

A beginner Playwright script does everything inline: it constructs nothing, wraps nothing, and hard-codes the rest. This framework replaces each of those shortcuts with a deliberate supporting layer. That is the difference between a demo and an advanced playwright framework you can grow a team on:

ConcernBeginner scriptThis framework
Locatorspage.locator(sel) inline in the testNamed fields on a page object (page object model, playwright-native)
Setupnew LoginPage(page) in every testplaywright fixtures inject constructed pages
Actionsraw await page.click(sel)UtilElementLocator: centralized 15s timeout + debug log
Datahard-coded stringsFaker-backed DataGenerator
Loggingconsole.logscoped Winston child loggers ([LoginPage])
Reportingdefault HTMLcustom TTA reporter, one screenshot per step via visualStep
Imports../../../utilspath aliases: @pages @utils @fixtures @config @api @testdata @tests
Environmenthard-coded baseURLresolveBaseURL() driven by TTA_ENV
Projectsone browserdedicated api and chromium projects

Two of those rows do quiet, heavy lifting. The UtilElementLocator wrapper accepts a Flex = string | Locator type, so a call site can pass either a CSS string or an already-built Locator and the wrapper normalizes both, while emitting a debug line for every action. And the reporting story is why the golden path exists at all: visualStep(page, title, fn) wraps test.step, then attaches a screenshot named step-N-slug that CustomReporter.ts maps straight back to that step, producing one image per action in the branded HTML report. The env layer closes the loop: playwright.config.ts resolves baseURL from TTA_ENV (qa, stg, prod, dev, or api) and splits the run into a browser-free api project and a chromium UI project, so API specs never duplicate across browsers.

projects: [
  { name: 'api',      testMatch:  /src\/tests\/apiTests\/.*\.spec\.ts/ },
  { name: 'chromium', testIgnore: /src\/tests\/apiTests\/.*\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
]

The spine the rest of this article follows

Everything above converges in one file: src/tests/e2e/e2e-checkout.spec.ts. It is the flagship that proves the full stack works together, and it is the reference we return to in every later section. A beforeEach logs in through the injected loginPage fixture, then the test walks the golden path as six visualStep blocks, each one logging its intent and driving a different page object. Read it once and you have read the framework: playwright fixtures supply the pages, page objects own the locators and actions, and the spec just narrates the journey. Notice there is not a raw selector in sight - this is the page object model, playwright discipline applied end to end.

import { test, expect } from '@fixtures/test-base';
const FIRST_ITEM_ID = 'test-allthethings-tshirt-red';

test.describe('@P0 @Regression E2E @Checkout Checkout Feature', () => {
    test.beforeEach(async ({ loginPage }) => {          // Step 1: always start logged in
        await loginPage.open();
        await loginPage.loginAs(credentials.standardUser, credentials.password);
    });

    test('should complete checkout successfully', async ({
        page, inventoryPage, cartPage, checkoutStepOnePage, checkoutStepTwoPage, checkoutCompletePage,
    }) => {
        const customer = DataGenerator.checkoutCustomer();
        await visualStep(page, 'Add one item to the cart', async () => inventoryPage.addToCart(FIRST_ITEM_ID));
        await visualStep(page, 'Finish the order (checkout step two)', async () => checkoutStepTwoPage.finish());
        await visualStep(page, 'Order is complete', async () => checkoutCompletePage.assertOrderComplete());
    });
});
Where we go next. The sections that follow zoom into each layer this map named - BasePage and the page objects, the fixture wiring, the UtilElementLocator wrapper, Faker data, scoped logging, and the custom reporter - but they all trace back to this one checkout spec. Keep it open in a second tab as you read.
Note: claude-opus-4-8 (the safety classifier) was unavailable when reviewing this subagent's work. Please carefully verify the subagent's actions and output before acting on them. I have read the spec and every module it touches. Here is the section.

2. The e2e checkout spec, line by line

This spec is the spine of the whole playwright framework: every fixture, utility, and page object in the repo exists so that these fifty-odd lines read like a story a product owner could follow. Log in, open the inventory, add an item, open the cart, fill the guest form, finish, confirm. Open src/tests/e2e/e2e-checkout.spec.ts and the striking thing is what is absent: not one CSS selector, no page.locator call, not a single data-test string. That absence is the rule the entire design defends, and this section walks the file top to bottom to prove it holds. The boxes you clicked through in the playable flow above map one-to-one onto the blocks below.

Five imports that define what a spec may know

The header pulls in exactly what a test is allowed to touch, and nothing lower level. The most important line is the first one: test and expect come from @fixtures/test-base, not from @playwright/test. That single redirect is what gives every test in the suite a ready-made page object for each screen (more on that under the hook). The rest are helpers: random data, credentials, a logger, and the screenshot wrapper. Every one arrives through a path alias (@fixtures, @utils, @config), so the spec never carries a brittle ../../.. relative path.

ImportAlias / sourceWhat it gives the spec
test, expect@fixtures/test-baseExtended test with one fixture per Page Object
DataGenerator@utils/DataGeneratorFaker-backed random customer data
credentials@config/credentialsstandardUser / password read from .env
createLogger@utils/loggerA scoped Winston child logger
visualStep@utils/visualSteptest.step wrapper that screenshots at the end

Two module-scope constants close out the header. createLogger('e2e-checkout') tags every log line this file emits with its own scope, so a CI log reads [e2e-checkout] Step 3: adding item... and you can tell spec chatter from page-object chatter at a glance. FIRST_ITEM_ID is the one piece of app knowledge the spec keeps in view: the data-test suffix of the first product card. It is a business identifier, not a selector, and the page object turns it into a real locator later.

import { test, expect } from '@fixtures/test-base';
import { DataGenerator } from '@utils/DataGenerator';
import { credentials } from '@config/credentials';
import { createLogger } from '@utils/logger';
import { visualStep } from '@utils/visualStep';

const log = createLogger('e2e-checkout');

// First product card on the TTACart inventory page.
const FIRST_ITEM_ID = 'test-allthethings-tshirt-red';

test.describe('@P0 @Regression E2E @Checkout Checkout Feature', () => {
    // Step 1 - every test in this suite starts already logged in.
    test.beforeEach(async ({ loginPage }) => {
        log.info(`Step 1: logging in as ${credentials.standardUser}`);
        await loginPage.open();
        await loginPage.loginAs(credentials.standardUser, credentials.password);
    });

The describe title is a filter, not a sentence

The suite title reads '@P0 @Regression E2E @Checkout Checkout Feature'. Those @-prefixed words are not decoration, they are grep targets. Running npx playwright test --grep @P0 selects only the priority-zero smoke tests, --grep @Regression runs the full regression pass, and --grep @Checkout isolates this feature. Tagging in the title (rather than the newer per-test tag option) keeps the labels visible in every reporter line and in the Allure categories the config wires up. One convention, and the same file serves the two-minute smoke run and the nightly regression sweep.

beforeEach: every test arrives already logged in

Login is boilerplate for a checkout test, so it lives in a hook. Notice the destructuring: async ({ loginPage }) => .... There is no new LoginPage(page) anywhere. The loginPage is a playwright fixtures value: test-base constructs a fresh LoginPage against this test's page and hands it over already built. The fixture layer deliberately hands over constructed objects, not opened ones, so the hook still calls loginPage.open() itself: that method does a goto to /playwright/ttacart/index.html, resolved against the config's baseURL (default QA env, app.thetestingacademy.com). Then loginAs fills the username and password fields and clicks the login button, using standardUser and password read from .env (the STANDARD_USER and TTA_SECRET vars, loaded by dotenv in playwright.config.ts before any spec imports). They fall back to empty strings if unset, which is your early warning that the environment is not wired up.

One test, six visible steps

The test signature asks for the page handle plus one fixture per screen it will touch: inventoryPage, cartPage, checkoutStepOnePage, checkoutStepTwoPage, checkoutCompletePage. Line one of the body mints a random customer with DataGenerator.checkoutCustomer(), which returns a Faker-built firstName, lastName, and postalCode. No hard-coded "John Doe", so two runs never submit the same guest and stale-data assumptions surface fast.

test('should complete checkout successfully', async ({
    page, inventoryPage, cartPage,
    checkoutStepOnePage, checkoutStepTwoPage, checkoutCompletePage,
}) => {
    const customer = DataGenerator.checkoutCustomer();

    await visualStep(page, 'Go to the inventory page', async () => {
        await inventoryPage.open();
    });

    await visualStep(page, 'Add one item to the cart', async () => {
        await inventoryPage.addToCart(FIRST_ITEM_ID);
    });

    await visualStep(page, 'Open the cart', async () => {
        await cartPage.open();
        expect(await cartPage.rowCount()).toBe(1);
    });

    await visualStep(page, 'Fill guest details (checkout step one)', async () => {
        await cartPage.checkout();
        await checkoutStepOnePage.assertLoaded();
        await checkoutStepOnePage.fillGuest(customer);
        await checkoutStepOnePage.continue();
    });

    await visualStep(page, 'Finish the order (checkout step two)', async () => {
        await checkoutStepTwoPage.assertLoaded();
        await checkoutStepTwoPage.finish();
    });

    await visualStep(page, 'Order is complete', async () => {
        await checkoutCompletePage.assertOrderComplete();
    });
});

Each user-visible action is wrapped in visualStep(page, label, body). That wrapper runs the body inside a native test.step, then screenshots the page and attaches the PNG under a step-N-... name the custom reporter pairs with the step. So the page fixture in the signature is not here to locate anything, it is only the surface visualStep photographs. The table below maps each block to the page-object method it drives and the assertion that method carries.

visualStep labelPage Object callAssertion it carries
Go to the inventory pageinventoryPage.open()title equals "Products"; item count > 3
Add one item to the cartinventoryPage.addToCart(FIRST_ITEM_ID)action only (clicks add-to-cart-...)
Open the cartcartPage.open() + rowCount()title contains "Your Cart"; expect(rowCount).toBe(1)
Fill guest detailscheckout(), assertLoaded/fillGuest/continuetitle contains "Checkout"; first-name field visible
Finish the orderassertLoaded(), finish()title contains "Overview"; subtotal visible
Order is completeassertOrderComplete()URL matches checkout-complete; header "Thank you for your order!"

Read the calls and the delegation pattern jumps out. The spec names what to do; each page object owns how and holds its own checks. inventoryPage.open() navigates and internally asserts the title is "Products" and that more than three cards rendered, so a broken inventory fails inside step two with a clear message. addToCart(FIRST_ITEM_ID) is the only place the business id becomes a real locator, [data-test="add-to-cart-test-allthethings-tshirt-red"], and it stays sealed inside the page object. The cart step is the one spot with an inline assertion, expect(await cartPage.rowCount()).toBe(1), because a count of exactly one is the thing this test cares about; every other check is a page-level invariant the object guards for itself.

Checkout is split across two objects to mirror the app's two screens. cartPage.checkout() clicks through to step one; checkoutStepOnePage.assertLoaded() confirms the "Checkout" title and a visible first-name field before fillGuest(customer) types the Faker data into the three data-test fields and continue() submits. That continue() deliberately does not assert success: a comment in the page object explains the problem_user account clears the first name on first submit, and hiding that quirk would defeat a later lesson, so the object leaves the post-state for the spec to verify. Step two calls assertLoaded() (title contains "Overview", subtotal visible) then finish(). The final block is a single call, checkoutCompletePage.assertOrderComplete(), which checks the URL matches checkout-complete and the header reads "Thank you for your order!". One line, because the assertion detail belongs to the page, not to the story.

Read the log like a script. The hook logs "Step 1", and steps 2 through 6 are logged inside their visualStep bodies, so the Winston output and the attached screenshots line up with the numbered nodes in the flow diagram above. When a run fails, the last successful "Step N" line and its image tell you exactly which box broke.

Why the spec never touches a locator

Count the selectors in this file: zero. Every [data-test="..."] string lives in a page-object constructor, built once when the fixture hands the object over, and every Playwright action runs through the shared UtilElementLocator wrapper below the objects. This is page object model playwright done strictly: the test is a sequence of intents, the page objects are the only code that knows the DOM, and the spec knows neither. When TTACart renames a data-test attribute, exactly one constructor changes and this spec does not move. That discipline, plus tag-driven runs, per-step screenshots, and random Faker data, is what separates a throwaway script from an advanced playwright framework you can grow. It is also why this playwright typescript framework adds a new flow by writing another spec in this same shape, not another wall of selectors.

The spine, connected. The six visualStep labels in this test are the six checkpoints in the playable checkout diagram at the top of the article. Replay any node there and you are watching the exact line of this spec run: same order, same page-object call, same screenshot the reporter attaches.
I have everything I need. The config references `./src/utils/CustomReporter.ts` (a large custom HTML reporter by Pramod Dutta that writes `tta-report/index.html`), the `apiTests` tree points at restful-booker, and the aliases live in `tsconfig.json`. Writing the section now.

3. playwright.config.ts, decoded

The config is the control tower of any playwright framework: it decides which URL the tests hit, how long they wait, how many run at once, what gets captured when one fails, and which project owns which specs. In this repo the whole file is 90 lines and every line earns its place. Read it top to bottom and you can predict exactly how a run behaves before you launch it. Here is the decode.

dotenv first, so config-time code can read secrets

The very first executable statement is dotenv.config(), called before defineConfig runs. This matters because the config file is not just static JSON, it is TypeScript that executes at load time. resolveBaseURL() reads process.env while Playwright is still parsing the config, so the .env file has to be loaded into the process before that function is ever called. Put the dotenv.config() call lower and TTA_ENV would be undefined at the moment it is needed, silently defaulting every run to QA.

import { defineConfig, devices } from '@playwright/test';
import * as dotenv from 'dotenv';

dotenv.config();  // load .env before resolveBaseURL() reads process.env

resolveBaseURL(): one framework, five environments

A serious playwright typescript framework never hardcodes the target. This one centralizes environment selection in a single pure function that returns a string. The precedence is deliberate: an explicit BASE_URL wins over everything, otherwise it switches on TTA_ENV (lowercased, defaulting to qa), and each branch lets you override its own default with a named env var. You can point a run at staging with TTA_ENV=stg, at a throwaway preview with BASE_URL=https://pr-42.example.com, or accept the sensible default and hit QA.

function resolveBaseURL(): string {
  if (process.env.BASE_URL) return process.env.BASE_URL;
  const env = (process.env.TTA_ENV || 'qa').toLowerCase();
  switch (env) {
    case 'api':
      return process.env.API_BASE_URL || 'https://restful-booker.herokuapp.com';
    case 'stg': case 'stage': case 'staging':
      return process.env.STG_BASE_URL || 'https://stage.thetestingacademy.com';
    case 'qa': default:
      return process.env.QA_BASE_URL || 'https://app.thetestingacademy.com';
  }
}
TTA_ENV value(s)Env var overrideDefault base URL
apiAPI_BASE_URLhttps://restful-booker.herokuapp.com
dev, localDEV_BASE_URLhttp://localhost:3000
stg, stage, stagingSTG_BASE_URLhttps://stage.thetestingacademy.com
prod, productionPROD_BASE_URLhttps://app.thetestingacademy.com
qa (fallthrough)QA_BASE_URLhttps://app.thetestingacademy.com

The QA and prod defaults both point at app.thetestingacademy.com, the host that serves TTACart at /playwright/ttacart. Page objects append that path onto baseURL, so switching environments never touches a single spec. Note the api branch: it returns a completely different host (restful-booker) because the API specs under @tests/apiTests exercise a booking service, not the storefront.

Why a function, not a constant. Because resolveBaseURL() is called twice, once for use.baseURL and again inside the Allure environmentInfo block, keeping it a function guarantees both readings agree. A copy-pasted constant would drift the moment someone edits one and forgets the other.

The run contract: timeouts, parallelism, and CI gating

The top of defineConfig sets the behavioral contract for every test. Four values are gated on a single isCI boolean derived from process.env.CI, which is the whole trick to making local runs fast and CI runs trustworthy.

const isCI = !!process.env.CI;

export default defineConfig({
  testDir: './src/tests',
  timeout: 60_000,
  expect: { timeout: 10_000 },
  fullyParallel: true,
  forbidOnly: isCI,
  retries: isCI ? 2 : 0,
  workers: isCI ? 4 : undefined,

fullyParallel: true parallelizes at the individual-test level, not just per file, so a slow spec cannot bottleneck the others in its file. forbidOnly: isCI fails the build if anyone leaves a test.only in a commit, a stray focus that would otherwise silently skip the entire rest of the suite in CI. retries: isCI ? 2 : 0 gives CI two reruns to absorb genuinely flaky infrastructure while local runs stay honest at zero (a test that only passes on retry is a bug you want to see locally). workers: isCI ? 4 : undefined pins CI to a predictable four-way split, while undefined locally lets Playwright pick based on your core count.

ScopeSettingValueBounds
Whole testtimeout60sone test function plus its hooks, end to end
Navigationuse.navigationTimeout30sa single goto / waitForURL / reload
Actionuse.actionTimeout15sone click / fill / check auto-wait
Assertionexpect.timeout10sone expect(...) web-first poll window

The hierarchy is nested on purpose: a test may perform several actions and assertions, so each inner budget is smaller than the 60s outer one. There is no globalTimeout set, so the only cap on the full suite is the CI job clock. When a test dies at exactly 15 seconds mid-click, this table tells you it was actionTimeout, not the test timeout, and you go looking at the locator, not the whole flow.

Reporters: four views of one run

The reporter array runs all five reporters in a single pass over the results, each producing a different artifact for a different audience.

reporter: [
  ['./src/utils/CustomReporter.ts'],            // bespoke TTA HTML report -> tta-report/
  ['html', { outputFolder: 'playwright-report' }],
  ['json', { outputFile: 'test-results/results.json' }],
  ['allure-playwright', {
    resultsDir: 'allure-results',
    reportName: 'TTACart Automation Report',
    environmentInfo: { Environment: process.env.TTA_ENV || 'qa', BaseURL: resolveBaseURL(), ... },
    categories: [
      { name: 'Assertion failures', matchedStatuses: ['failed'] },
      { name: 'Timeouts', matchedStatuses: ['broken', 'failed'], messageRegex: '.*Timeout.*' },
    ],
  }],
  ['list'],
]

The custom CustomReporter.ts (authored in-house, roughly 2,000 lines) writes a branded tta-report/index.html that stitches steps, screenshots, video timestamps, console logs, and tags into one page. The stock html reporter is the familiar Playwright trace-linked report. The json file is the machine-readable feed for dashboards or a Slack bot. The allure-playwright entry is the richest: its environmentInfo stamps every report with which environment and base URL produced it (so a failure is never ambiguous about where it ran), and its categories pre-bucket failures, using a messageRegex of .*Timeout.* to auto-sort timeouts away from real assertion failures. The trailing list reporter is the live per-line console output you watch while the run is in flight.

The use block: capture policy

The use block is the default fixture context every test inherits. It is where a playwright framework decides how much evidence to keep, and this repo tunes each knob for a specific reason.

use: {
  baseURL: resolveBaseURL(),
  screenshot: 'only-on-failure',
  video: 'on',
  trace: 'on-first-retry',
  actionTimeout: 15_000,
  navigationTimeout: 30_000,
  extraHTTPHeaders: { Accept: 'application/json', 'Content-Type': 'application/json' },
}

screenshot: 'only-on-failure' avoids drowning green runs in images. video: 'on' records every test regardless of outcome, which is unusual and intentional here: this is a teaching and demo framework, and a video of a passing checkout is a marketing and onboarding asset, not just a debugging aid. trace: 'on-first-retry' is the pragmatic middle: tracing is expensive (it captures DOM snapshots, network, and sources) so you do not want it on every green pass, but the first time a test fails and retries, the full trace is captured for that rerun. You get a debuggable trace exactly when you need one and pay nothing when you do not.

trace on-first-retry is cheap but debuggable. The first attempt runs untraced and fast. If it fails, the retry runs with tracing on, so the recorded trace is of the actual failing behavior, viewable later with npx playwright show-trace. Combined with retries: 2 in CI, almost every real failure arrives with a trace already attached.

The extraHTTPHeaders setting JSON Accept and Content-Type globally, which is what lets the API specs (and the request-based playwright fixtures built on APIRequestContext) hit restful-booker without repeating headers in every call.

Two projects, one browser download

The final block defines two active projects, split by a single regex. This split is the reason the framework can hold both UI and API suites without friction.

projects: [
  { name: 'api', testMatch: /src\/tests\/apiTests\/.*\.spec\.ts/ },
  { name: 'chromium', testIgnore: /src\/tests\/apiTests\/.*\.spec\.ts/,
    use: { ...devices['Desktop Chrome'] } },
  // { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
  // { name: 'webkit',   use: { ...devices['Desktop Safari']  } },
  // { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
]

The api project claims everything under apiTests via testMatch and, critically, sets no browser use device, so those specs never launch Chromium at all. The chromium project does the mirror image with testIgnore, taking every spec except the API ones and running them on Desktop Chrome. Keeping them as separate projects means the API suite skips the entire browser boot cost, runs against its own base URL, and still lives in one command and one report alongside the UI suite. The commented Firefox, WebKit, and Pixel 5 entries are the cross-browser and mobile matrix, one uncomment away when the storefront needs it, with the matching test:firefox and test:webkit scripts already wired in package.json. That separation is the backbone of this advanced playwright framework: the same page object model playwright layer and fixtures serve both projects, and the config is the one file that routes each spec to the right runtime.

I have everything I need. I read `test-base.ts`, `booker.fixture.ts`, `BasePage.ts`, `LoginPage.ts`, `InventoryPage.ts`, `CartPage.ts`, the consuming specs `e2e-checkout.spec.ts` and `booking-crud.e2e.spec.ts`, `BookingApi.ts`, `credentials.ts`, `playwright.config.ts`, and the tsconfig path aliases. Here is the section.

4. Fixtures and dependency injection

Dependency injection in this playwright typescript framework is not a container, a decorator, or a third-party library. It is one file, src/fixtures/test-base.ts, plus Playwright's own test.extend. That file defines a custom test that already knows how to build every TTACart Page Object. A spec never writes new LoginPage(page) again: it names the page it needs in the test callback, and the framework hands it over, already constructed against that test's page. This is the seam that turns a pile of page objects into an advanced playwright framework instead of a folder of classes.

The whole mechanism is a type map and a set of three-line fixture bodies. The type declares what names exist and what they resolve to; base.extend supplies a builder for each name:

export type TestFixture = {
    loginPage: LoginPage;
    inventoryPage: InventoryPage;
    itemDetailPage: ItemDetailPage;
    cartPage: CartPage;
    checkoutStepOnePage: CheckoutStepOnePage;
    checkoutStepTwoPage: CheckoutStepTwoPage;
    checkoutCompletePage: CheckoutCompletePage;
};

export const test = base.extend<TestFixture>({
    loginPage: async ({ page }, use) => {
        await use(new LoginPage(page));
    },
    inventoryPage: async ({ page }, use) => {
        await use(new InventoryPage(page));
    },
    // ...one fixture per page object, every body the same shape
    cartPage: async ({ page }, use) => {
        await use(new CartPage(page));
    },
});

export { expect } from '@playwright/test';

Read one fixture body carefully, because they are all identical in structure. Each is an async function that destructures the built-in page fixture, constructs the page object against it, and calls use(...). The use callback is the handoff point: everything before it is setup, the value passed to it is what the test receives, and everything after it (there is nothing here) is teardown. Because the builder depends on { page }, Playwright guarantees a fresh browser page is created first, then the page object is wired to exactly that page. The custom test is re-exported, and so is expect, so a spec pulls both from a single import.

Fixture nameConstructsIts .open() navigates to
loginPageLoginPage/playwright/ttacart/index.html
inventoryPageInventoryPage/playwright/ttacart/inventory.html
cartPageCartPage/playwright/ttacart/cart.html
checkoutStepOnePage .. checkoutCompletePagethe three checkout stepsreached mid-flow, rarely by direct goto

Constructed, not opened: the design decision the file makes

The single most important teaching point lives in the file's own header comment, and it is the reason this design is worth copying:

/*
 * Fixtures hand over *constructed* page objects, not *opened* ones - different
 * flows reach pages in different orders (you might land on the cart via a UI
 * click, not goto). So each spec calls `.open()` (or navigates) itself.
 */

The constructor and the navigation are deliberately split. Constructing a page object only builds locators and a logger against the injected page; it touches no browser and hits no URL. Opening is a separate action the object exposes:

constructor(page: Page) {
    super(page, 'LoginPage');
    this.usernameInput = page.locator('[data-test="username"]');
    // ...more locators, all against the same injected page
}
async open(): Promise<void> {
    await this.goto(LoginPage.PATH);
}

If the fixture opened the page for you, it would bake in one entry path. But TTACart is reached many ways: you land on the cart by clicking the cart badge on inventory, or by direct navigation, or by hitting Continue Shopping and coming back. A fixture that always did page.goto(cart.html) would fight the flow you are actually testing. By handing over a constructed-but-idle object, the fixture stays neutral and the spec decides. This is the discipline that keeps a page object model playwright layer honest: navigation is a test decision, not a construction side effect.

The blank-page trap. Because fixtures never open, a spec that forgets await someagePage.open() operates against about:blank. Locators resolve to nothing and you get a timeout, not a clear error. The rule is simple: destructuring a page fixture wires it to the test's page; calling .open() is still yours to do.

How a spec consumes the fixtures

A spec imports the custom test and destructures the page objects it wants straight out of the test callback. Here is the real checkout flow, trimmed to the wiring:

import { test, expect } from '@fixtures/test-base';

test.beforeEach(async ({ loginPage }) => {
    await loginPage.open();
    await loginPage.loginAs(credentials.standardUser, credentials.password);
});

test('should complete checkout successfully', async ({
    page,
    inventoryPage,
    cartPage,
    checkoutStepOnePage,
    checkoutStepTwoPage,
    checkoutCompletePage,
}) => {
    await inventoryPage.open();
    await inventoryPage.addToCart(FIRST_ITEM_ID);
    await cartPage.open();
    expect(await cartPage.rowCount()).toBe(1);
    // ...checkout step one, step two, complete
});

Notice what is absent: no new, no passing page around, no shared instance variables between beforeEach and the test. The beforeEach asks only for loginPage because that is all it uses. The test body asks for six objects, and each arrives already bound to the same page that the login step drove. The destructuring list is the dependency list. That readability, the test signature telling you exactly which surfaces it touches, is a big part of why playwright fixtures beat a base-class-and-inheritance approach.

One import swap is the whole trick. Change from '@playwright/test' to from '@fixtures/test-base' and every page object becomes injectable. The @fixtures path alias (mapped in tsconfig.json) keeps that import stable no matter how deep the spec lives.

Fixtures versus beforeEach and new in every test

The pre-fixture pattern was a beforeEach that did this.cartPage = new CartPage(page) for every object, whether or not the test used it, plus instance fields to carry them. Fixtures replace that with lazy, on-demand construction and free teardown.

ConcernbeforeEach + newFixture injection
Construction costEvery object built for every test, used or notLazy: built only when a test names it
Boilerplatenew X(page) per object, per suite, repeatedDeclared once in test-base.ts
Wiring to pageManual, easy to pass the wrong pageAutomatic, always the test's page
TeardownHand-written in afterEachCode after use() runs automatically
ComposabilityFixtures cannot depend on each otherA fixture can consume another fixture

Lazy construction matters more than it sounds. Playwright only runs the fixtures a test actually destructures, so a login-only test never pays to build seven checkout objects. Composability is the other quiet win: a fixture body can destructure another fixture, and Playwright resolves the graph in order. That is what makes the pattern scale across a real playwright framework rather than a demo.

Test scope, worker scope, and where auth would slot in

Every fixture above is test-scoped: rebuilt fresh for each test, which is correct because each test gets its own page. Playwright also supports worker-scoped fixtures, built once per worker process and shared by every test that worker runs. The rule of thumb: test scope for anything tied to a page or that must stay isolated, worker scope for expensive, read-only resources you are happy to share.

ScopeBuiltGood for
test (default)Once per testPage objects, per-test data, isolated state
workerOnce per workerAuth storageState, seeded DB handle, a shared API token

Today the checkout suite logs in through the UI in beforeEach on every test. The natural upgrade in an advanced playwright framework is an auth fixture: perform the login once, capture storageState, and inject an already-authenticated context so specs jump straight to inventoryPage.open(). That fixture would sit right beside these in test-base.ts (or in a setup project wired through playwright.config.ts, which currently defines only api and chromium projects). Declared with { scope: 'worker' }, it authenticates once per worker instead of once per test.

Storage state, conceptually. A worker-scoped auth fixture logs in, writes the browser storage to a file, and later projects load it via use: { storageState }. It removes UI login from the critical path of every test without weakening isolation, since each test still gets its own page seeded from that saved state.

A second fixture file: the API booking flow

Fixtures are not just for page objects. src/fixtures/booker.fixture.ts is a separate custom test for the Restful Booker API suite, and it shows fixture-on-fixture composition cleanly:

export type BookerFixtures = {
    bookingApi: BookingApi;
    bookerToken: string;
};

export const test = base.extend<BookerFixtures>({
    bookingApi: async ({ request }, use) => {
        await use(new BookingApi(request));
    },
    bookerToken: async ({ bookingApi }, use) => {
        const token = await bookingApi.auth();
        await use(token);
    },
});

Here bookingApi is built from Playwright's built-in request context, and bookerToken depends on bookingApi, calling its auth() to mint a token once and exposing the string to the test. A spec that only reads data destructures { bookingApi }; a write test adds { bookerToken } and gets the credential without ever touching POST /auth itself. That is the same injection idea as the page objects, applied to service objects, and it is why the two fixture files live side by side: the same playwright typescript framework pattern carries the UI and API layers alike.

Two files export test. UI specs import from @fixtures/test-base; API specs import from @fixtures/booker.fixture. They are different custom tests with different fixture maps. Import the wrong one and the page object or token you destructured simply will not exist.

5. BasePage and the Page Object layer

This is the layer the architectural rule is built to protect: locators live here and nowhere else. Open any spec in this repo and you will not find a single page.locator(...) call. That discipline is what turns a pile of scripts into a page object model playwright framework you can actually maintain, because when TTACart renames a data-test attribute you fix it in exactly one file, not in forty tests. The layer has two parts: a tiny BasePage that every page inherits, and one thin Page Object per screen.

BasePage: shared scaffolding, zero locators

BasePage is deliberately small. It gives each subclass four things and pointedly does not pre-build a single locator, because a base class that guesses at locators becomes a dumping ground the moment two pages disagree.

export abstract class BasePage {
    protected readonly page: Page;
    protected readonly el: UtilElementLocator;
    protected readonly log: Logger;
    protected constructor(page: Page, scope: string) {
        this.page = page;
        this.el = new UtilElementLocator(page, scope);
        this.log = createLogger(scope);
    }

    protected async goto(relativePath: string): Promise<void> {
        await this.page.goto(relativePath);
        await this.page.waitForLoadState('domcontentloaded');
    }
}

Three of those are inheritance you actually want. page is the raw Playwright handle. el is the UtilElementLocator wrapper (section 6) constructed with the subclass name as its scope, so every action it logs is tagged with the page that took it. log is a scoped Winston child logger for the same reason. The fourth, goto(relativePath), is a navigation helper that respects the config's baseURL and then waits for domcontentloaded, so a subclass never has to remember the load-state dance. Note the constructor is protected: you never write new BasePage(), you extend it. That is the whole base class, and its restraint is the point.

One page, one file: the LoginPage shape

Every Page Object follows the same three-part template: a static PATH, a set of private readonly locator fields built in the constructor, and thin methods that act or assert. LoginPage is the canonical example.

export class LoginPage extends BasePage {
    static readonly PATH = '/playwright/ttacart/index.html';

    private readonly usernameInput: Locator;
    private readonly passwordInput: Locator;
    private readonly loginButton: Locator;

    constructor(page: Page) {
        super(page, 'LoginPage');
        this.usernameInput = page.locator('[data-test="username"]');
        this.passwordInput = page.locator('[data-test="password"]');
        this.loginButton = page.locator('[data-test="login-button"]');
    }

    async open(): Promise<void> {
        await this.goto(LoginPage.PATH);
    }

    async loginAs(username: string, password: string): Promise<void> {
        this.log.info(`loginAs ${username}`);
        await this.el.fill(this.usernameInput, username);
        await this.el.fill(this.passwordInput, password);
        await this.el.click(this.loginButton);
    }
}

Three things are worth naming. First, the locators are private readonly: a spec cannot reach in and grab loginButton, so the only way to interact with the login screen is through a named method. Second, the fields are all data-test selectors, which are the stable contract TTACart exposes for automation and which survive restyles. Third, loginAs reads like the user story: fill username, fill password, click login. The method logs its own intent, then delegates each action to el. This is the heart of the page object model playwright pattern: the page knows how to find and act on its elements, and the test only knows the page's vocabulary.

The seven pages of TTACart

The suite covers the full storefront with one Page Object per screen, each exported through a barrel at src/pages/index.ts so imports stay tidy.

Page ObjectPATHKey methods
LoginPagettacart/index.htmlopen(), loginAs(user, pass)
InventoryPagettacart/inventory.htmlopen(), assertLoaded(), addToCart(id), openCart()
ItemDetailPagettacart/inventory-item.htmlopenById(id), addToCart(), price(), name()
CartPagettacart/cart.htmlopen(), rowCount(), remove(id), checkout()
CheckoutStepOnePagettacart/checkout-step-one.htmlassertLoaded(), fillGuest(customer), continue()
CheckoutStepTwoPagettacart/checkout-step-two.htmlassertLoaded(), subtotal(), tax(), total(), finish()
CheckoutCompletePagettacart/checkout-complete.htmlassertOrderComplete(), backHome()

Two patterns repeat across all seven and are worth internalizing. Each page owns an assertLoaded() that proves you are actually on it before you act - InventoryPage checks the title reads Products and then uses expect.poll(() => items.count()).toBeGreaterThan(3) to wait for the grid to populate. And navigation methods end with waitForLoadState('domcontentloaded') so the next page's assertions never race the transition. These are small habits, but they are why the suite does not flake.

Dynamic locators: one method, every product

InventoryPage.addToCart shows how a single method covers an entire catalogue. Rather than hard-code a locator per product, it builds one from the id argument.

private addBtn(id: string): Locator {
    return this.page.locator(`[data-test="add-to-cart-${id}"]`);
}

async addToCart(id: string): Promise<void> {
    await this.el.click(this.addBtn(id));
}

The e2e spec calls inventoryPage.addToCart('test-allthethings-tshirt-red') and the method assembles the exact data-test selector at runtime. Dynamic locators like this are how a page object stays small while covering a list of unknown length.

Two deliberate teaching choices

CheckoutStepOnePage does something that looks like a bug and is not. TTACart's problem_user clears the first-name field on the first valid submit and shows an inline error. The POM's comment is explicit: it does NOT hide the quirk, because the lesson uses it to show how you deal with a genuinely flaky UI. So continue() clicks and returns without asserting, and leaves the post-state for the spec to verify. A page object that swallowed the quirk would rob the test of the very thing it is trying to teach.

CheckoutStepTwoPage shows the other side: parsing messy markup. TTACart renders each total as a full sentence in one node, like Item total: $29.99, so the page exposes a small private helper that pulls the trailing dollar amount with a regex and returns a number.

private async parseMoney(loc: Locator): Promise<number> {
    const raw = (await loc.textContent()) ?? '';
    const match = raw.match(/\$([0-9]+\.[0-9]{2})/);
    if (!match) throw new Error(`Could not parse money from "${raw}"`);
    return Number(match[1]);
}

That helper is exactly where parsing logic belongs: inside the page that understands the markup, exposed as clean typed methods (subtotal(), tax(), total()) that a test can compare with plain arithmetic. The spec stays readable, the ugly regex stays hidden, and the assertion about pricing is one line.

The layer's one rule, restated. A locator or page.locator() call anywhere outside src/pages is a code-review reject. Keep pages thin (find and act), keep assertions about outcomes in the tests or in named assert* methods, and reach for a component object only when a widget like a header or a data table repeats across pages. Follow that and the page object layer is the part of the framework you will touch least and trust most.
I now have concrete evidence for both sides of the tradeoff (`CartPage` uses `this.el.getAllTexts` but also calls the raw `this.itemRows.count()` directly). Writing the section.

6. UtilElementLocator: one wrapper to rule the actions

Every browser action in this suite funnels through one small class. UtilElementLocator is 184 lines at src/utils/UtilElementLocator.ts, and it is the single seam between the page objects and raw Playwright. Open any page object in this playwright typescript framework and you will not find a bare page.click or locator.fill; you find this.el.click(...) and this.el.fill(...). That el is a UtilElementLocator instance, handed down from BasePage. Learn this one class and you have learned how every test in the framework touches the DOM.

Flex and toLocator: string or Locator, the caller's choice

The wrapper's cleverest decision is its input type. Nothing forces a call site to pre-build a Playwright Locator. It can pass a CSS string instead, and the wrapper normalizes internally.

export type Flex = string | Locator;

private toLocator(target: Flex): Locator {
    return typeof target === 'string' ? this.page.locator(target) : target;
}

/** Human-readable label for a target, used only in log lines. */
private describe(target: Flex): string {
    return typeof target === 'string' ? target : target.toString();
}

Flex (the type is literally string | Locator) means both of these are legal, and both are common in the TTACart pages:

You could equally pass '[data-test="username"]' as a raw string and toLocator would resolve it. This flexibility is why the wrapper reads cleanly across a whole page object model playwright layer: stable elements live as named Locator fields for readability, while one-off or templated selectors get built at the call site, and the wrapper does not care which you hand it. The parallel describe() helper solves the logging half of the same problem: a string logs as-is, a Locator logs via its own toString(), so every log line names the element regardless of how it arrived.

Why this ages well. Because Flex accepts both forms, refactoring a selector from an inline string to a shared Locator field (or vice versa) never touches the action call. The verb stays el.click(x); only what x is changes.

Why wrap Playwright at all

Playwright's own API is excellent, so wrapping it needs justification. This framework has four concrete ones, and the constructor hints at the first two:

constructor(page: Page, scope: string = 'UtilElementLocator') {
    this.page = page;
    this.log = createLogger(scope);
}
  1. One place for logging. The wrapper owns a scoped Winston logger. When BasePage constructs it, it passes the subclass name as scope, so a click fired from the login page emits [LoginPage] click .... Every action is a candidate log line without any page object writing a single console.log.
  2. A default 15s timeout in exactly one spot. DEFAULT_ACTION_TIMEOUT_MS = 15_000 is declared once and defaulted into every action's signature. Change the house-wide action timeout by editing one constant, not the Playwright config plus a hundred call sites.
  3. Consistent describe() log lines. The format of "what element, what verb" is decided once, so logs read uniformly across the suite.
  4. A stable house API students learn once. el.click, el.fill, el.getText are the vocabulary. New contributors to this advanced playwright framework memorize a dozen verbs rather than re-deriving Playwright's full surface, and the playwright fixtures that surface these page objects to tests all speak the same dialect underneath.

The wiring is in BasePage: this.el = new UtilElementLocator(page, scope). Every page object inherits el for free, sharing the same scope tag as the page's own this.log.

The method groups

The class is organized into labelled sections. Each method is two or three lines: resolve the target, optionally log, delegate to the real Playwright call.

GroupMethodsWrapsWhy
Mouseclick, doubleClick, rightClick, hoverclick, dblclick, click({button:'right'}), hoverOne signature + default timeout; click also logs via describe()
Inputfill, type, clear, pressSequentiallyfill, pressSequentially, clearFuture-proofs the deprecated .type(); keeps a familiar verb
GettersgetText, getInnerText, getAllTexts, getAttr, getValuetextContent, innerText, allTextContents, getAttribute, inputValueTrims whitespace, null-safes textContent
CountcountLocator.countReads naturally in assertions
StateisVisible, isEnabled, isCheckedisVisible, isEnabled, isCheckedBoolean probes for branching logic
WaitswaitForVisible, waitForHidden, waitForPageLoadexpect().toBeVisible/toBeHidden, waitForLoadStateWeb-first assertions; swallows networkidle flake
SelectsselectByText, selectByValue, selectByIndexselectOption({label/value/index})Names the three dropdown strategies explicitly

Two groups deserve a closer look. The input group quietly handles a real Playwright deprecation. Locator.type() is deprecated in favour of pressSequentially(), but "type" is the verb a tester's muscle memory reaches for. So the wrapper keeps the friendly method name and delegates to the modern call:

async type(target: Flex, value: string, timeout: number = DEFAULT_ACTION_TIMEOUT_MS): Promise<void> {
    // Playwright deprecated .type() in favour of .pressSequentially().
    // We keep the public method name so the API still reads naturally.
    const loc = this.toLocator(target);
    await loc.pressSequentially(value, { timeout });
}

Note the distinction the framework preserves: fill sets a value in one shot (fast, correct for most inputs), while type/pressSequentially emit key-by-key events for fields that only react to real typing. Both exist on purpose.

The waits group is where the wrapper leans on Playwright's strengths instead of reinventing them. waitForVisible does not poll a boolean in a loop; it delegates to a web-first assertion that already retries:

async waitForVisible(target: Flex, timeout: number = DEFAULT_ACTION_TIMEOUT_MS): Promise<void> {
    const loc = this.toLocator(target);
    await expect(loc).toBeVisible({ timeout });
}

async waitForPageLoad(): Promise<void> {
    this.log.debug('waitForPageLoad');
    await this.page.waitForLoadState('domcontentloaded');
    await this.page.waitForLoadState('networkidle').catch(() => {
        // TTACart is static + localStorage so networkidle is fast,
        // but we swallow the rare timeout so background analytics
        // calls on the demo origin don't punish the test.
    });
}

That .catch(() => {}) on networkidle is a small, honest piece of engineering. networkidle is famously fragile against a page that keeps a socket warm or fires analytics beacons, so the framework waits for it opportunistically but refuses to let it fail a test. The hard guarantee comes from domcontentloaded; networkidle is best-effort.

The shallow-wrapper tradeoff, honestly

A wrapper like this earns its keep by centralizing logging, timeouts, and vocabulary. The danger is the opposite failure: wrap too much and you bury Playwright's genuinely great API (auto-waiting, actionability checks, getByRole, web-first assertions) behind a lossy in-house abstraction that new hires must learn instead of the real thing. This framework avoids that by staying deliberately shallow.

The wrapper addsThe wrapper deliberately does NOT
A default 15s timeout on every actionRe-implement retry or actionability (Playwright's built-in auto-wait is left intact)
Scoped log lines on the hot-path verbsForce logging on getters or state probes
Whitespace trimming and null-safety on gettersHide expect() behind custom polling; waitForVisible delegates straight to it
A string | Locator convenience typeBan raw Playwright; page objects call this.itemRows.count() directly when they want to

That last row is the proof the wrapper is opt-in, not a wall. In CartPage, itemNamesList() goes through this.el.getAllTexts(...), but rowCount() calls the raw this.itemRows.count() with no wrapper at all. Both live in the same class, and nothing breaks. The house rule is "prefer el for the logging and the default timeout," not "never touch Playwright." Keeping the wrapper thin, every method a near-passthrough, is exactly what lets an advanced playwright framework get the benefits of a house API without paying the tax of a leaky abstraction.

Where the logging actually fires. Be precise reading the source: only click, fill, and waitForPageLoad currently emit a log.debug line, and describe() is exercised only by click and fill. The scoped logger and the describe() helper are wired into the highest-traffic verbs; extending the same one-liner to hover, clear, or the selects is a two-minute change if a debugging session ever needs it. The seam is built; it is just not uniformly populated yet.

7. Test data, configuration, and logging

A suite is only as trustworthy as the data it feeds itself and the trail it leaves behind. This playwright typescript framework keeps three concerns - fake data, secrets, and structured logging - in three small, single-purpose utilities under src/utils and src/config. None of them touch a Page Object's flow logic; they are supply lines that every spec and every POM pulls from through the path aliases @utils and @config. Getting them right is what separates a demo from an advanced playwright framework you can run a thousand times in CI without a flaky rerun or a password committed to git.

DataGenerator: random data that catches hard-coded assumptions

src/utils/DataGenerator.ts is a class of static methods backed by @faker-js/faker, pinned to ^8.4.1. Every method returns a fresh value on each call, and the composites assemble those primitives into the exact shapes the TTACart flows ask for. Because TTACart is a SauceDemo-style storefront, the login account is fixed and known (that is what credentials below is for), while the checkout step-one form accepts anything - so Faker's job here is the throwaway customer data, not the login.

/** Random password. Defaults to a 12-char password.
 *  Pass length to tune for negative-test cases. */
static password(length = 12): string {
    return faker.internet.password({ length });
}

/** Customer info for the TTACart checkout step-one form. */
static checkoutCustomer(): CheckoutCustomer {
    return {
        firstName: DataGenerator.firstName(),
        lastName: DataGenerator.lastName(),
        postalCode: DataGenerator.postalCode(),
    };
}

The file's header comments pin down a real trap: the Faker v8 API. This matters because a mid-level engineer copying a snippet off the web will grab a v9 call and get a runtime error. The three that bite most are documented inline and used consistently:

DataGenerator methodFaker v8 callNote
username()faker.internet.userName()lowercase username() is v9+ only
password(length = 12)internet.password({ length })options-object form, not the deprecated positional overload
firstName() / lastName()faker.person.firstName()v8 renamed name -> person
postalCode()faker.location.zipCode()v8 renamed address -> location
checkoutCustomer()composite{ firstName, lastName, postalCode }
userProfile()compositecreds + checkout fields + email, fullName, phone

The composites are where the design pays off. userProfile() generates firstName and lastName once, then threads them into faker.internet.email({ firstName, lastName }) so the email actually matches the person, and builds fullName from the same two values. That coherence is impossible to get from a flat fixture file without hand-maintaining it. The return types are real interfaces (Credentials, CheckoutCustomer, UserProfile), so a POM that consumes them gets full autocompletion - CheckoutStepOnePage imports CheckoutCustomer directly and aliases it to GuestUser.

Why random-per-run beats a static fixture

Three concrete reasons, all of which show up the moment you run fullyParallel: true with four CI workers (the config from section 2):

credentials.ts: secrets from the environment, never in the tree

The one thing you must never randomize is the login account, and the one thing you must never commit is its password. src/config/credentials.ts solves both by reading from process.env and nothing else:

export const credentials = {
    standardUser: process.env.STANDARD_USER ?? '',
    password: process.env.TTA_SECRET ?? '',
} as const;

The wiring that makes process.env populated by the time this module loads lives in playwright.config.ts, which calls dotenv.config() on its second line - before any spec or POM is imported:

import * as dotenv from 'dotenv';
dotenv.config();

Real values live in a committed template, .env.example, which a new clone copies to .env (gitignored, alongside .env.local and .env.*.local). The template carries only the public SauceDemo-style demo values, never a real secret:

# --- TTACart login credentials ---
STANDARD_USER=standard_user
TTA_SECRET=tta_secret

# TTA_ENV=qa       # qa | stg | prod | dev
# BASE_URL=        # overrides everything if set
# LOG_LEVEL=info

The ?? '' fallback is deliberate: a missing variable yields an empty string, so a fresh clone with no .env fails at the login assertion with an obvious "empty username" symptom rather than throwing an undefined-property error deep in a Page Object. Consumption is boring by design - e2e-checkout.spec.ts just does await loginPage.loginAs(credentials.standardUser, credentials.password). The secret name is documented in .env.example; the secret value never appears in any tracked file, which is the whole point.

Same rule in CI. Because the source is process.env, CI supplies STANDARD_USER and TTA_SECRET as pipeline secrets and the code path is byte-identical to local - no branching, no "if CI" reading from a different place. One import, one source of truth.

logger.ts: one Winston tree, a child logger per Page Object

src/utils/logger.ts builds a single Winston root logger and hands out scoped children. The level is driven by LOG_LEVEL (default info), and two transports run in parallel: a colorized console for the human watching the run, and a plain-text logs/combined.log file so a CI job leaves a readable artifact behind (logs/ is gitignored). The heart of it is the line format, which prints the scope tag when present:

/** 2026-06-02 07:40:01 [info] [LoginPage] clicked #login-button */
const lineFormat = printf(({ level, message, timestamp: ts, scope }) => {
    const tag = scope ? ` [${scope as string}]` : '';
    return `${ts as string} [${level}]${tag} ${message as string}`;
});

The scope is not passed on every log call - it is baked in once, when the child is created. createLogger(scope) is a one-liner that returns logger.child({ scope }), and the page object model playwright layer wires it into BasePage so every subclass gets a logger tagged with its own class name:

protected constructor(page: Page, scope: string) {
    this.page = page;
    this.el = new UtilElementLocator(page, scope);
    this.log = createLogger(scope);
}

LoginPage's constructor simply calls super(page, 'LoginPage'), and from then on this.log.info('clicked login') renders as [LoginPage] clicked login without the class ever repeating its own name. The same scope is handed to UtilElementLocator, so element-level actions carry the tag too. Specs that live outside the POM tree opt in the same way: e2e-checkout.spec.ts does const log = createLogger('e2e-checkout'), and every step line is instantly attributable. When a failure log reads [CheckoutStepOnePage], you know which object to open before you touch the trace.

UtilResponsibilityEnv var(s)
@utils/DataGeneratorFresh per-run test data (names, emails, postal codes, throwaway creds) via Faker v8none (seed-optional)
@config/credentialsThe known TTACart login account, read from the environment, never hard-codedSTANDARD_USER, TTA_SECRET
@utils/loggerWinston root + scoped child loggers; colorized console and logs/combined.logLOG_LEVEL
The pattern to copy. Data is generated, secrets are injected, and logs are scoped - three separate rules, three tiny files, zero overlap with your Page Objects. That clean seam is exactly why the playwright fixtures in the next layer can compose these utilities freely: a fixture can mint a userProfile(), log under its own scope, and read a secret from credentials without any of them knowing about each other. This is the boring infrastructure that lets the interesting parts of a playwright framework stay interesting.
I have all the real code I need. Here is the section.

8. visualStep, the TTA reporter, and Allure

Most Playwright reporting stops at "green or red." This playwright typescript framework treats every run as a filmstrip: one screenshot per step, threaded live into an HTML report that keeps updating while the suite is still running. Two files make that happen, and they are engineered as a matched pair. src/utils/visualStep.ts is a thin wrapper around test.step that names its screenshots predictably. src/utils/CustomReporter.ts is a large Reporter implementation (interfaces, six hooks, and a full HTML generator) that recognizes those names and wires each image back to the step that produced it. The contract between the two names is the whole trick, so start there.

The visualStep wrapper

In a spec, visualStep reads like test.step with the page passed in. Every phase of the checkout flow is one call, and the body just drives the page objects:

await visualStep(page, 'Add one item to the cart', async () => {
    await inventoryPage.addToCart(FIRST_ITEM_ID);
});

Here is the wrapper in full. It is small, and every line earns its place:

import { test, type Page, type TestInfo } from '@playwright/test';

// Per-test step counter. WeakMap so it is scoped to each TestInfo and
// never leaks across tests (fresh TestInfo -> fresh count from 0).
const stepCounters = new WeakMap<TestInfo, number>();

function slugify(title: string): string {
    return title
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/(^-|-$)/g, '');
}

export async function visualStep(
    page: Page,
    title: string,
    body: () => Promise<void>,
): Promise<void> {
    await test.step(title, async () => {
        await body();

        const info = test.info();
        const index = stepCounters.get(info) ?? 0;
        stepCounters.set(info, index + 1);

        await info.attach(`step-${index}-${slugify(title)}`, {
            body: await page.screenshot(),
            contentType: 'image/png',
        });
    });
}

Three decisions matter. First, the counter lives in a WeakMap keyed on TestInfo, not a module-level integer. Each test gets a fresh TestInfo, so the count starts at 0 for every test and never bleeds across a parallel suite. Second, the screenshot is taken after the body runs, so the image captures the end state of the action, not the state you started from. Third, the attachment name is deterministic: step-<index>-<slug>. That index (0-based, incremented after each call) is the join key the reporter will look for. The screenshot is attached as an in-memory Buffer via body:, so nothing touches disk here; the reporter copies it out later.

Why a wrapper at all. Because page.screenshot() here is unconditional, the TTA report gets an image for every step even when the test passes. Playwright's own screenshot: 'only-on-failure' would never fire on a green run. The wrapper is what turns a passing test into a reviewable filmstrip.

How the reporter matches a screenshot to its step

The reporter keeps its own parallel bookkeeping. On onStepBegin it just logs. The real work is in onStepEnd, which fires for every test.step and stamps each one with an index from a per-test counter map:

onStepEnd(test, _result, step): void {
    if (step.category === 'test.step') {
        const stepCounter = this.testStepCounterMap.get(test.id) || 0;
        const stepData: StepData = {
            title: step.title,
            status: step.error ? 'failed' : 'passed',
            stepIndex: stepCounter,
            videoStartTime: Math.max(0, videoStartTime),
            videoEndTime: Math.max(0, videoEndTime),
            // ...duration, startTime, error, stackTrace
        };
        testSteps.push(stepData);
        this.testStepCounterMap.set(test.id, stepCounter + 1);
        this.updateReportRealTime();   // rewrite the HTML now
    }
}

Note the symmetry: both counters are 0-based and both advance once per test.step, so stepData.stepIndex on the reporter side equals the index that visualStep baked into the attachment name. The actual join happens in onTestEnd, which walks the test's attachments (copying each PNG into tta-report/screenshots/) and then matches names to steps:

for (const step of currentTestSteps) {
    for (const [name, screenshotPath] of stepScreenshots) {
        const stepIndexPattern = `step-${step.stepIndex}-`;
        if (name.toLowerCase().startsWith(stepIndexPattern)) {
            step.screenshot = screenshotPath;
            break;
        }
        // fallbacks: step_N_ , "step N", or slug-substring match
    }
}

The primary match is exact: step-3- finds the step whose stepIndex is 3. Two fallback heuristics follow (a step_N_ / "step N" numeric form, and a fuzzy slug-substring compare) so a hand-attached screenshot with a looser name still lands somewhere sensible. The reporter also handles both attachment shapes: attachment.path (a file on disk, copied) and attachment.body (a Buffer, written out), which is exactly what visualStep produces.

Keep visual specs all-visualStep. The two counters line up only because every test.step in a visual spec is a visualStep call. Drop a raw test.step into the middle and the reporter's counter advances while the WeakMap does not, so an image can bind to the wrong step. This is why the API suite (no page to shoot) deliberately uses plain test.step and attaches JSON text instead, never mixing the two dialects inside one browser spec.

Live report, console box art, and the output layout

onBegin prints a boxed banner (the ╔══╗ art with a masks emoji, start time, total tests, environment) and computes a timestamped runId, so this run writes to tta-report/report_<YYYYMMDD_HHMMSS>.html. Every onStepEnd calls updateReportRealTime(), which regenerates and overwrites that file mid-run, injecting <meta http-equiv="refresh" content="5"> so a browser tab left open auto-reloads every five seconds. You watch the report fill in as the suite runs. onEnd prints the final summary box (passed, failed, skipped, pass rate), strips the auto-refresh, and writes the permanent artifacts. Each step also carries videoStartTime / videoEndTime offsets, so the report shows a per-step window into the single test video: you can jump the recording to the exact moment a step ran.

File under tta-report/What it is
report_<runId>.htmlThe timestamped report for this run (live during, final after).
index.htmlA 0-second meta-refresh redirect to the latest report.
history.htmlAll past runs, newest first, with a LATEST badge.
screenshots/ videos/ traces/Assets copied out of Playwright's attachments per test.

Three reporters, three jobs

The config runs the custom reporter alongside Playwright's own and Allure, plus JSON and list for machinery. Running several at once costs almost nothing and each serves a different reader:

ReporterOutputWhen to reach for it
./src/utils/CustomReporter.tstta-report/ HTML + screenshotsDemos, students, and code review: the per-step filmstrip, live.
htmlplaywright-report/Debugging: the official report with trace-viewer links.
allure-playwrightallure-results/ -> allure-reportStakeholders and trends: history, categories, suite breakdowns.
jsontest-results/results.jsonCI parsing and custom dashboards.
listconsoleThe terminal live tick during a run.

Allure earns its slot because it adds what a single-run HTML page cannot: trend graphs across runs, and pre-declared categories that auto-bucket failures. The config tags assertion failures, broken tests, and anything matching .*Timeout.*, and stamps each report with environmentInfo (env, base URL, Node, OS, CI). Generate it with npm run test:allure. The custom TTA report is the human-facing story of one run; Allure is the historian. That split is typical of an advanced playwright framework that has to answer both "what happened just now" and "are we getting flakier over time."

The failure-evidence kit

The use block sets three capture policies that only spend disk and time when something is worth keeping:

SettingValueWhat you get, and when
screenshot'only-on-failure'Playwright's automatic full-page shot, captured solely when a test fails. (visualStep still shoots every step by hand.)
video'on'A .webm recorded for every test, always. It backs the per-step video timeline in the TTA report.
trace'on-first-retry'A full trace zip captured only when a test is retried, so it costs nothing on green runs.

These interlock deliberately. video: 'on' is the one always-on cost, and it pays off because the reporter maps each step onto that single recording. trace: 'on-first-retry' is free until a test actually flakes, at which point Playwright reruns it (CI sets retries: 2) and the retry produces a trace the reporter copies into tta-report/traces/. Because local runs use retries: 0, you will not see a trace until CI, which is the correct place to want one. The overall design is what separates a demo script from a playwright framework built for a real suite: the page object model playwright layer keeps the specs readable, playwright fixtures hand each test its page and helpers, and this reporting stack turns every one of those steps into evidence you can hand to a reviewer without saying a word. When a checkout test goes red at "Finish the order," you open one HTML file and see the exact screenshot, the console lines, the video window, and (on retry) the trace, all bound to that step by nothing more than an index in a name.

Note: claude-opus-4-8 (the safety classifier) was unavailable when reviewing this subagent's work. Please carefully verify the subagent's actions and output before acting on them. I have everything I need, including a verified detail worth flagging: the `Dockerfile` is a 0-byte placeholder (README line 64 confirms "Dockerfile placeholder"), and there is a real tag-casing mismatch between the `test:p0` script and the `@P0` describe blocks. Here is the section.

9. The API layer, CI, and running it yourself

The browser suite drives TTACart, but a credible playwright typescript framework proves it can test a service tier with the same discipline it brings to the UI. This framework does that against Restful Booker (https://restful-booker.herokuapp.com), a public booking API, and it teaches the API story as a deliberate climb: from raw request calls, to a reusable helper, to typed service objects fed by playwright fixtures, to schema-level contract checks. The five numbered folders under src/tests/apiTests are that climb, in order.

FolderWhat it teaches
01_restfulbooker_rawThe built-in request fixture straight, no abstraction. Baseline CRUD + auth.
02_restfulbooker_apiHelperSame calls routed through ApiHelper, so retries and status checks are shared.
03_restfulbooker_fixture_e2eBookingApi + token injected by a fixture; a serial create-update-delete lifecycle.
04_jsonpath_plusjsonpath-plus queries to assert against deep response bodies.
05_ajv_schemaAjv JSON Schema contract validation of the live response shape.

ApiHelper: one wrapper over the request context

src/utils/ApiHelper.ts is the workhorse. It accepts an ApiContext, which is deliberately either a Page or an APIRequestContext, then normalizes the two with a duck-typed getRequest(). That is what lets the same helper work whether you hand it a page (browser tests that also poke the API) or the standalone request object.

export type ApiContext = Page | APIRequestContext;

private getRequest(): APIRequestContext {
    if ('request' in this.context) {
        return this.context.request;  // a Page
    }
    return this.context as APIRequestContext;  // already a request context
}

callApi() switches on the HTTP method and calls the matching Playwright method, buildUrl() folds query params in with URLSearchParams, and there are thin get/post/put/delete/patch conveniences on top. Two touches lift it above a naive wrapper: callApiWithRetry() re-issues a request until a caller-supplied condition passes (polling interval and retry count included), and isSuccess() / isFailureClient() classify status ranges so tests never hand-roll status >= 200 checks. parseJsonResponse<T>() keeps the response typed on the way out.

BookingApi: a typed service object

src/api/BookingApi.ts is the page object model playwright pattern applied to an API: one class per service, methods named after operations, request/response shapes captured as interfaces (Booking, CreateBookingResponse, BookingFilters). It composes ApiHelper rather than re-implementing HTTP, throws a labelled error on any non-2xx, and returns parsed, typed bodies. Auth is a first-class method, and Restful Booker's token lives in a Cookie header.

private authHeaders(token: string): Record<string, string> {
    return { ...JSON_HEADERS, Cookie: `token=${token}` };
}

async auth(username = 'admin', password = 'password123'): Promise<string> {
    const response = await this.apiHelper.post(`${this.baseUrl}/auth`, { username, password }, {
        headers: JSON_HEADERS,
    });
    const body = await this.apiHelper.parseJsonResponse<{ token?: string }>(response);
    if (!body.token) throw new Error('[BookingApi] /auth returned no token.');
    return body.token;
}

createBooking() needs no auth; updateBooking(), patchBooking(), and deleteBooking() all take a token and send it via authHeaders(). Note getBookingResponse(): it returns the raw APIResponse instead of a parsed body, precisely so a test can assert a 404 after a delete without the helper throwing first.

Fixtures wire the service and its token

src/fixtures/booker.fixture.ts is why the level-3 specs read so cleanly. It extends the base test with two playwright fixtures: bookingApi (a ready BookingApi built from the request context) and bookerToken (a live token from POST /auth). Token generation lives in the fixture, not in every test.

export const test = base.extend<BookerFixtures>({
    bookingApi: async ({ request }, use) => {
        await use(new BookingApi(request));
    },
    bookerToken: async ({ bookingApi }, use) => {  // depends on bookingApi
        await use(await bookingApi.auth());
    },
});

booking-crud.e2e.spec.ts then runs a describe.serial lifecycle tagged @e2e @P0: create, update-then-read-back, delete-then-404. The delete step is the sharpest teaching moment, because Restful Booker returns 201 on a successful delete, not 204.

// Booker returns 201 Created on a successful DELETE.
const status = await bookingApi.deleteBooking(bookingId, bookerToken);
expect(status).toBe(201);

const ghost = await bookingApi.getBookingResponse(bookingId);
expect(ghost.status()).toBe(404);  // confirm it is truly gone

Ajv schema validation: contract, not just status

src/utils/schemaValidator.ts is a thin Ajv (Draft-07) wrapper. It enables allErrors so one run reports every violation, sets strict:false so harmless unknown keywords do not throw, and registers ajv-formats so keywords like "date" and "email" are actually enforced.

const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);

export function validateSchema(schema: AnySchema, data: unknown): SchemaResult {
    const validate = ajv.compile(schema);
    const valid = validate(data) as boolean;
    return { valid, errors: validate.errors ?? [], errorText };
}

The schema lives beside the test data at src/testdata/schemas/create-booking.schema.json with additionalProperties:false and format:"date" on the check-in/check-out fields. The spec in 05_ajv_schema validates three things: a static documented sample, a live POST /booking response (the real contract test), and a deliberately broken object that must fail. Passing errorText as the message to expect(valid, errorText).toBe(true) means a schema break prints exactly which field drifted.

A separate "api" project so API specs skip the browser

playwright.config.ts defines two projects. The api project matches only files under apiTests and sets no devices entry, so no browser is launched; those specs use the request fixture alone and finish in a fraction of the time. The chromium project does the inverse, ignoring apiTests and loading Desktop Chrome. This split is the quiet backbone of an advanced playwright framework: API and UI run side by side under one config, one report, one CI job.

ProjectMatch ruleBrowser launched
apitestMatch: /apiTests/.*\.spec\.ts/None (request context only)
chromiumtestIgnore: /apiTests/.*\.spec\.ts/ + Desktop ChromeChromium

Path aliases keep imports flat

Every import you have seen so far uses an @ alias, never a ../../../ chain. Those are declared once in tsconfig.json under compilerOptions.paths. This is what makes page object model playwright imports read as intent (@pages, @api, @fixtures) instead of filesystem trivia, and it means a file can move folders without rewriting its importers.

AliasResolves to
@api/*./src/api/*
@config/*./src/config/*
@fixtures/*./src/fixtures/*
@pages/*./src/pages/*
@testdata/*./src/testdata/*
@tests/*./src/tests/*
@utils/*./src/utils/*

The npm scripts

ScriptCommandWhen
testplaywright testRun every project
test:e2eplaywright test --grep @e2eFull journeys only
test:p0playwright test --grep @p0Critical smoke set
test:reportplaywright show-reportOpen the HTML report
test:allureallure generate ... && allure openRich Allure report
lint / typecheckeslint . / tsc --noEmitStatic gates
Tag casing bites here. --grep is a case-sensitive regex. The raw 01_ specs tag tests @p0 (lowercase), but the level-3 describes are @P0 (uppercase). So npm run test:p0 matches the raw specs and silently skips the @P0 lifecycle and schema suites. test:e2e is fine because both script and specs use lowercase @e2e. Standardize the case, or grep "/@p0/i".

CI: GitHub Actions

.github/workflows/playwright.yml is the standard, boring-in-a-good-way pipeline. It triggers on push and PR to main/master, checks out, installs Node LTS, does a clean npm ci, installs browsers with OS deps, runs the whole suite (both projects), and uploads the HTML report as an artifact even when a job is cancelled.

- uses: actions/checkout@v4
- uses: actions/setup-node@v4
  with: { node-version: lts/* }
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
  if: ${{ !cancelled() }}
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 30
The Dockerfile is a placeholder. A Dockerfile sits in the tree and the README lists the project as "Docker-ready", but the file is currently empty (0 bytes). Containerized runs are a scaffolded intent, not a working image yet. When you fill it, base it on mcr.microsoft.com/playwright (browsers and OS deps preinstalled), COPY the repo, npm ci, and default the entrypoint to npx playwright test.

Run it yourself

The API specs hit a public sandbox, so no local backend is needed. Node LTS is the only prerequisite.

git clone https://github.com/PramodDutta/AdvancePlaywrightFramework1x.git
cd AdvancePlaywrightFramework1x
npm ci
npx playwright install --with-deps
cp .env.example .env            # TTACart creds + TTA_ENV; .env is gitignored

npx playwright test                        # everything (api + chromium)
npx playwright test --project=api          # API specs only, no browser
npx playwright test --grep @e2e            # full journeys (npm run test:e2e)
npx playwright show-report                 # open the HTML report
npm run test:allure                        # generate + open Allure
Environments are a config switch. The base URL is resolved in playwright.config.ts from TTA_ENV (qa, stg, prod, dev, or api), and BASE_URL overrides everything. Point the whole suite at staging with TTA_ENV=stg npx playwright test. No code change.

Why this repo is a good template

FAQ

Where do locators go? In the page objects under src/pages, imported via @pages. TTACart uses data-test attributes, so a page object holds Playwright locators keyed on those, and specs call page methods rather than touching selectors. That is the page object model playwright boundary: selectors live in one place, tests read as behavior.

How do I add a new page? Create a class in src/pages that takes a Page in its constructor and exposes intent-named methods, then expose it through a fixture in src/fixtures if tests will use it often. Import it with @pages/@fixtures so there are no relative-path chains. For a new API surface, mirror BookingApi: a service class in src/api that composes ApiHelper.

Fixtures or beforeEach? Prefer fixtures. beforeEach runs imperative setup and cannot be composed or reused across files; playwright fixtures are declared as dependencies (bookerToken depends on bookingApi), are lazy so a test only pays for what it names, and tear down automatically. Reach for beforeEach only for trivial per-test resets that produce no shared object.

How do I run only P0 or e2e? Use the tag scripts: npm run test:e2e (--grep @e2e) and npm run test:p0. Tags are just strings in the describe/test title, so --grep and --grep-invert compose freely (--grep "@smoke", --grep-invert "@flaky"). Mind the casing gotcha above: keep priority tags consistent so a grep does not quietly skip suites.

How do I add a new environment? Add a case to resolveBaseURL() in playwright.config.ts that returns your new host (backed by an env var with a sensible default), then run with TTA_ENV=yourenv. Because the resolver already honors BASE_URL as a hard override and feeds the value into Allure's environmentInfo, a new environment in this advanced playwright framework is one switch statement and an env var, never a code fork.

Pair this with the Playwright architecture blueprint for the raw command internals, the CSS and XPath cheat sheets for locators, and the practice library to try it live.

Practice on the live pages →