diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..24bcc19
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,60 @@
+# Copilot instructions — ecommerce-e2e-playwright
+
+## What this repo is
+End-to-end UI test suite for the demo shop **automationexercise.com**.
+Python + Playwright (sync API) + pytest, Page Object Model. 12 tests across
+auth, search, cart, checkout. ~365 LOC. Runs headless in CI on push and PR
+across Chromium/Firefox/WebKit.
+
+## Layout
+- `pages/` — Page Object Model. One class per page; `base_page.py` holds shared
+ navigation (with 5xx retry) and the `parse_price` helper. `flows.py` holds
+ cross-page actions (e.g. `register_via_ui`) that several tests reuse.
+- `tests/` — one file per area (`test_auth`, `test_search`, `test_cart`,
+ `test_checkout`, `test_smoke`). Tests read as business scenarios; no selectors.
+- `utils/data_generator.py` — Faker-backed factories returning `TypedDict`s
+ (`User`, `PaymentCard`). Every test gets fresh, unique data.
+- `conftest.py` — fixtures: API-backed user setup/teardown, ad blocking via
+ network route, and a `base_url` fixture (sourced from `pytest.ini`).
+- `.github/workflows/tests.yml` — CI matrix.
+
+## How to build / run
+```bash
+pip install -r requirements.txt # versions are pinned, do not loosen
+playwright install chromium
+pytest # full suite, chromium
+pytest -n 4 # parallel (data isolation supports it)
+pytest --browser firefox # other engines
+```
+Tests hit a live third-party site, so transient failures are expected; CI uses
+`--reruns 2`. A green local run is the bar before committing.
+
+## Conventions to follow in reviews and changes
+- **No `time.sleep()`.** Use Playwright auto-waiting and web-first `expect`.
+- **Selectors, in order of preference:** `data-qa` attributes, then stable CSS
+ classes, then `get_by_role`. Avoid matching on display text (breaks under
+ i18n) and never use XPath. Note: some buttons are `` with no `href`, so
+ they have NO ARIA `link` role — `get_by_role("link", ...)` will not find them;
+ use the stable class (e.g. `a.check_out`).
+- **Page objects hold selectors and actions; tests hold assertions and flow.**
+ Don't put raw locators in test files. Reusable multi-page journeys go in
+ `pages/flows.py`, not duplicated across tests.
+- **Test data is typed.** Extend the `TypedDict`s in `utils/data_generator.py`
+ rather than passing loose dicts. The signup form and account API use different
+ field names (`first_name` vs `firstname`) — map explicitly, don't rename keys.
+- **Every test owns its data** and must be order-agnostic and xdist-safe (unique
+ emails via uuid). Don't introduce shared mutable state between tests.
+- **Pin dependencies.** `requirements.txt` uses `==`; bump deliberately, never
+ switch to `>=`.
+- **Don't hardcode `--browser` in `pytest.ini`** — it must stay overridable from
+ the CLI and the CI matrix.
+
+## What to flag in PR review
+- Any `time.sleep`, XPath, or text-based selector for a button.
+- New locators living in `tests/` instead of `pages/`.
+- Loosened dependency pins.
+- Tests that depend on execution order or another test's side effects.
+- New cross-page flows duplicated instead of added to `pages/flows.py`.
+
+## Trust these instructions
+Only search the codebase if something here is incomplete or proves wrong.
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a882246..dd1ec41 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -1,10 +1,17 @@
name: e2e-tests
-on: [push, workflow_dispatch]
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
@@ -17,20 +24,25 @@ jobs:
run: pip install -r requirements.txt
- name: Install browser
- run: playwright install --with-deps chromium
+ run: playwright install --with-deps ${{ matrix.browser }}
- # --reruns guards against the demo site's intermittent 503 load-shedding;
- # genuine regressions still fail consistently and turn the badge red
+ # -n 4 runs tests in parallel (data isolation in conftest.py supports it).
+ # Capped at 4 rather than `auto`: the demo site sheds load under heavy
+ # concurrency, so more workers means more flaky 503s, not more speed.
+ # --reruns guards against that intermittent load-shedding; genuine
+ # regressions still fail consistently and turn the badge red.
- name: Run e2e tests
run: >
- pytest --html=report.html --self-contained-html
+ pytest --browser ${{ matrix.browser }}
+ -n 4
+ --html=report.html --self-contained-html
--tracing retain-on-failure --reruns 2 --reruns-delay 30
- name: Upload report and failure artifacts
uses: actions/upload-artifact@v4
if: always()
with:
- name: test-report
+ name: test-report-${{ matrix.browser }}
path: |
report.html
test-results/
diff --git a/.gitignore b/.gitignore
index 9b9dea0..d0747fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,12 @@
-# virtualenv
-.venv/
-
-# python
+# Python
__pycache__/
*.py[cod]
-
-# pytest / playwright artifacts
+.venv/
+venv/
.pytest_cache/
-test-results/
+
+# Test artifacts
report.html
+test-results/
playwright-report/
+trace.zip
diff --git a/README.md b/README.md
index 2fd8190..0265d7a 100644
--- a/README.md
+++ b/README.md
@@ -24,14 +24,21 @@ money path a real client pays to keep green.
## Stack
-Playwright (Chromium) · pytest · Page Object Model · Faker · pytest-html · GitHub Actions
+Playwright (Chromium · Firefox · WebKit) · pytest · Page Object Model · Faker ·
+pytest-html · pytest-xdist · GitHub Actions
## Run it locally
```bash
pip install -r requirements.txt
-playwright install chromium
+playwright install
pytest --html=report.html --self-contained-html
+
+# parallel (data isolation supports it)
+pytest -n 4
+
+# a different engine
+pytest --browser firefox
```
## Design decisions
@@ -42,11 +49,17 @@ pytest --html=report.html --self-contained-html
through the site's [REST API](https://automationexercise.com/api_list) in fixtures
([conftest.py](conftest.py)), so tests are independent and order-agnostic.
- **Resilient selectors** — `get_by_role` / `data-qa` attributes, no brittle XPath.
+ Where the markup fights ARIA (e.g. an `` with no `href`, which
+ therefore has no `link` role), a stable CSS class beats matching on display text.
+- **Typed test data** — user/payment factories return `TypedDict`s ([utils/data_generator.py](utils/data_generator.py)),
+ so editors and `pyright` catch a missing or misspelled field before it reaches the form.
- **No `time.sleep()`** — Playwright auto-waiting and web-first `expect` assertions only.
- **Ad traffic is blocked at network level** — the target site serves ad iframes that can
cover buttons and steal clicks; a route filter in `conftest.py` keeps runs deterministic.
+- **Cross-browser in CI** — the GitHub Actions matrix runs the full suite on Chromium,
+ Firefox and WebKit on every push ([.github/workflows/tests.yml](.github/workflows/tests.yml)).
- **Failure diagnostics** — screenshots on failure locally; in CI also Playwright traces,
- uploaded with the HTML report as a workflow artifact.
+ uploaded with the HTML report as a per-browser workflow artifact.
## Field notes from automating this target
@@ -70,8 +83,6 @@ Real-world issues found and handled while building the suite:
## What I'd add next
-- Cross-browser matrix in CI (Firefox, WebKit) — one-line change with `--browser`.
-- Parallel execution with `pytest-xdist` (data isolation already supports it).
- Visual regression checks for key pages.
- An API test layer reusing the same data factories.
- Adopt an open-source target (e.g. RealWorld/Conduit) to file real bug reports upstream.
diff --git a/conftest.py b/conftest.py
index 889b128..ec594d6 100644
--- a/conftest.py
+++ b/conftest.py
@@ -4,14 +4,21 @@
import pytest
from playwright.sync_api import Playwright
-from utils.data_generator import generate_user
+from utils.data_generator import User, generate_user
# The target site is ad-supported; ad iframes sometimes cover buttons and
# intercept clicks, so all ad/analytics requests are dropped at network level.
AD_HOSTS = re.compile(
- r"(googlesyndication|doubleclick|adservice|google-analytics|googletagmanager)"
+ r"(googlesyndication|doubleclick|adservice|google-analytics|googletagmanager|fundingchoices)"
)
+# pytest-playwright reads `base_url` from pytest.ini, but that value is NOT
+# propagated to xdist workers (-n auto), so navigations there hit "/" with no
+# host. Re-expose it as a fixture sourced from the ini so parallel runs work.
+@pytest.fixture(scope="session")
+def base_url(request) -> str:
+ return request.config.getini("base_url")
+
@pytest.fixture(autouse=True)
def block_ads(context):
@@ -26,7 +33,7 @@ def api(playwright: Playwright, base_url: str):
client.dispose()
-def _delete_account(api, user: dict) -> None:
+def _delete_account(api, user: User) -> None:
# idempotent: a 404 response code for an already-deleted account is fine
api.delete(
"/api/deleteAccount",
diff --git a/pages/cart_page.py b/pages/cart_page.py
index 837b9ab..271abc8 100644
--- a/pages/cart_page.py
+++ b/pages/cart_page.py
@@ -34,4 +34,6 @@ def remove_item(self, index: int = 0) -> None:
self.rows.nth(index).locator(".cart_quantity_delete").click()
def proceed_to_checkout(self) -> None:
- self.page.get_by_text("Proceed To Checkout").click()
+ # The button is an without href, so it has no
+ # ARIA "link" role; target the stable class instead of brittle text.
+ self.page.locator("a.check_out").click()
diff --git a/pages/checkout_page.py b/pages/checkout_page.py
index 6116e82..2d7146f 100644
--- a/pages/checkout_page.py
+++ b/pages/checkout_page.py
@@ -1,6 +1,7 @@
from playwright.sync_api import Page
from pages.base_page import BasePage
+from utils.data_generator import PaymentCard
class CheckoutPage(BasePage):
@@ -33,7 +34,7 @@ def __init__(self, page: Page):
"Congratulations! Your order has been confirmed!"
)
- def pay(self, card: dict) -> None:
+ def pay(self, card: PaymentCard) -> None:
page = self.page
page.locator('[data-qa="name-on-card"]').fill(card["name_on_card"])
page.locator('[data-qa="card-number"]').fill(card["card_number"])
diff --git a/pages/flows.py b/pages/flows.py
new file mode 100644
index 0000000..a356d2a
--- /dev/null
+++ b/pages/flows.py
@@ -0,0 +1,26 @@
+"""Cross-page user flows that several tests share.
+
+Lives outside the page objects because it spans pages (login -> signup),
+and outside conftest because it's a reusable action, not a fixture.
+"""
+from playwright.sync_api import Page, expect
+
+from pages.login_page import LoginPage
+from pages.signup_page import SignupPage
+from utils.data_generator import User
+
+
+def register_via_ui(page: Page, user: User) -> SignupPage:
+ """Full UI signup journey ending logged in. Returns the SignupPage
+ so callers can chain logout/delete-account checks."""
+ login = LoginPage(page)
+ login.open()
+ login.start_signup(user["name"], user["email"])
+
+ signup = SignupPage(page)
+ expect(signup.form_heading).to_be_visible()
+ signup.fill_account_details(user)
+ expect(signup.account_created_message).to_be_visible()
+ signup.continue_after_signup()
+ expect(signup.logged_in_as(user["name"])).to_be_visible()
+ return signup
diff --git a/pages/signup_page.py b/pages/signup_page.py
index cb61b91..3282790 100644
--- a/pages/signup_page.py
+++ b/pages/signup_page.py
@@ -1,6 +1,7 @@
from playwright.sync_api import Page
from pages.base_page import BasePage
+from utils.data_generator import User
class SignupPage(BasePage):
@@ -14,7 +15,7 @@ def __init__(self, page: Page):
self.account_created_message = page.locator('[data-qa="account-created"]')
self.account_deleted_message = page.locator('[data-qa="account-deleted"]')
- def fill_account_details(self, user: dict) -> None:
+ def fill_account_details(self, user: User) -> None:
page = self.page
page.get_by_role("radio", name="Mr.").check()
page.locator('[data-qa="password"]').fill(user["password"])
diff --git a/pytest.ini b/pytest.ini
index 965878c..7e2915d 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,4 +1,6 @@
[pytest]
testpaths = tests
base_url = https://automationexercise.com
-addopts = --browser chromium --screenshot only-on-failure --output test-results
+# Browser is NOT hardcoded here so `--browser firefox|webkit` works from the CLI
+# and the CI matrix. Default (chromium) comes from pytest-playwright.
+addopts = --screenshot only-on-failure --output test-results
diff --git a/requirements.txt b/requirements.txt
index a6a88dc..a8a6739 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,7 @@
-pytest>=8.0
-pytest-playwright>=0.5.2
-pytest-html>=4.1
-pytest-rerunfailures>=14.0
-faker>=33.0
+# Pinned for reproducible CI runs. Bump deliberately, not by surprise.
+pytest==9.1.0
+pytest-playwright==0.8.0
+pytest-html==4.2.0
+pytest-rerunfailures==16.3
+pytest-xdist==3.8.0
+faker==40.23.0
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 03114e6..4017f3e 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -1,30 +1,20 @@
"""Authentication: signup, login, and the negative paths real clients always ask about."""
from playwright.sync_api import Page, expect
+from pages.flows import register_via_ui
from pages.login_page import LoginPage
-from pages.signup_page import SignupPage
+from utils.data_generator import User
-def test_register_new_user(page: Page, new_user: dict):
- login = LoginPage(page)
- login.open()
- expect(login.signup_heading).to_be_visible()
- login.start_signup(new_user["name"], new_user["email"])
-
- signup = SignupPage(page)
- expect(signup.form_heading).to_be_visible()
- signup.fill_account_details(new_user)
- expect(signup.account_created_message).to_be_visible()
-
- signup.continue_after_signup()
- expect(signup.logged_in_as(new_user["name"])).to_be_visible()
+def test_register_new_user(page: Page, new_user: User):
+ signup = register_via_ui(page, new_user)
# removing the account through the UI doubles as a check of that flow
signup.delete_account()
expect(signup.account_deleted_message).to_be_visible()
-def test_login_with_valid_credentials(page: Page, registered_user: dict):
+def test_login_with_valid_credentials(page: Page, registered_user: User):
login = LoginPage(page)
login.open()
expect(login.login_heading).to_be_visible()
@@ -33,7 +23,7 @@ def test_login_with_valid_credentials(page: Page, registered_user: dict):
expect(login.logged_in_as(registered_user["name"])).to_be_visible()
-def test_login_with_wrong_password_shows_error(page: Page, registered_user: dict):
+def test_login_with_wrong_password_shows_error(page: Page, registered_user: User):
login = LoginPage(page)
login.open()
login.login(registered_user["email"], "wrong-" + registered_user["password"])
@@ -42,7 +32,7 @@ def test_login_with_wrong_password_shows_error(page: Page, registered_user: dict
expect(login.logged_in_as(registered_user["name"])).not_to_be_visible()
-def test_signup_with_taken_email_shows_error(page: Page, registered_user: dict):
+def test_signup_with_taken_email_shows_error(page: Page, registered_user: User):
login = LoginPage(page)
login.open()
login.start_signup("Another Person", registered_user["email"])
diff --git a/tests/test_checkout.py b/tests/test_checkout.py
index 064ab8f..277c011 100644
--- a/tests/test_checkout.py
+++ b/tests/test_checkout.py
@@ -3,10 +3,9 @@
from pages.cart_page import CartPage
from pages.checkout_page import CheckoutPage, PaymentPage
-from pages.login_page import LoginPage
+from pages.flows import register_via_ui
from pages.product_page import ProductPage
-from pages.signup_page import SignupPage
-from utils.data_generator import generate_payment_card
+from utils.data_generator import User, generate_payment_card
def test_checkout_requires_login(page: Page):
@@ -21,16 +20,9 @@ def test_checkout_requires_login(page: Page):
expect(cart.checkout_login_prompt).to_be_visible()
-def test_full_purchase_flow(page: Page, new_user: dict):
+def test_full_purchase_flow(page: Page, new_user: User):
"""Registration -> product -> cart -> checkout -> payment -> confirmation."""
- login = LoginPage(page)
- login.open()
- login.start_signup(new_user["name"], new_user["email"])
- signup = SignupPage(page)
- signup.fill_account_details(new_user)
- expect(signup.account_created_message).to_be_visible()
- signup.continue_after_signup()
- expect(signup.logged_in_as(new_user["name"])).to_be_visible()
+ signup = register_via_ui(page, new_user)
products = ProductPage(page)
products.open()
diff --git a/utils/data_generator.py b/utils/data_generator.py
index 925c013..e9d1bd1 100644
--- a/utils/data_generator.py
+++ b/utils/data_generator.py
@@ -1,12 +1,44 @@
"""Test data factories. Every test creates its own user, so tests stay independent."""
import uuid
+from typing import TypedDict
from faker import Faker
fake = Faker()
-def generate_user() -> dict:
+class User(TypedDict):
+ """Shape of a generated user. Field names mirror the signup form;
+ the account API renames some of them (see conftest.registered_user)."""
+
+ title: str
+ name: str
+ email: str
+ password: str
+ birth_date: str
+ birth_month: str
+ birth_year: str
+ first_name: str
+ last_name: str
+ company: str
+ address1: str
+ address2: str
+ country: str
+ state: str
+ city: str
+ zipcode: str
+ mobile_number: str
+
+
+class PaymentCard(TypedDict):
+ name_on_card: str
+ card_number: str
+ cvc: str
+ expiry_month: str
+ expiry_year: str
+
+
+def generate_user() -> User:
first_name = fake.first_name()
last_name = fake.last_name()
return {
@@ -31,7 +63,7 @@ def generate_user() -> dict:
}
-def generate_payment_card() -> dict:
+def generate_payment_card() -> PaymentCard:
return {
"name_on_card": fake.name(),
"card_number": fake.credit_card_number(),