From ee11ff1c8bfbe55511c8d4e8e52f68a11a77145f Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:56:18 +0200 Subject: [PATCH 01/12] build: pin dependency versions for reproducible CI --- requirements.txt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 From b2e4a602527edd06978efdc9adc8a7062bf03fb2 Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:56:18 +0200 Subject: [PATCH 02/12] test: stop hardcoding --browser in pytest.ini so CLI override works --- pytest.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 3459bf7553529be989651b2b2a93d5a2ae28d141 Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:56:18 +0200 Subject: [PATCH 03/12] fix(cart): target stable a.check_out class instead of brittle text The Proceed To Checkout button is an without href, so it has no ARIA link role; matching on display text also breaks under i18n. --- pages/cart_page.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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() From f92a2d806b883c71c2c6ff1e4fc3ec83a3298916 Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:56:24 +0200 Subject: [PATCH 04/12] refactor: type test data with TypedDict (User, PaymentCard) Propagate the types through page-object signatures so pyright catches a missing or misspelled field before it ever reaches the form. --- conftest.py | 13 ++++++++++--- pages/checkout_page.py | 3 ++- pages/signup_page.py | 3 ++- utils/data_generator.py | 36 ++++++++++++++++++++++++++++++++++-- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/conftest.py b/conftest.py index 889b128..5dcf931 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-base-url 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/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/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/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(), From 5fcecffcb4488ffb175ba643992ed9855fe8e321 Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:56:24 +0200 Subject: [PATCH 05/12] refactor(tests): extract register_via_ui flow, kill duplicate signup steps --- pages/flows.py | 26 ++++++++++++++++++++++++++ tests/test_auth.py | 24 +++++++----------------- tests/test_checkout.py | 16 ++++------------ 3 files changed, 37 insertions(+), 29 deletions(-) create mode 100644 pages/flows.py 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/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() From 07ad5108501813fc8365dfe36b3a93fcd4ee93ae Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:56:31 +0200 Subject: [PATCH 06/12] ci: cross-browser matrix (chromium/firefox/webkit) + parallel -n 4 --- .github/workflows/tests.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a882246..75028c2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,10 @@ on: [push, workflow_dispatch] jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] steps: - uses: actions/checkout@v4 @@ -17,20 +21,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/ From 52af7c6523828c90ef803d126e5272bcf94ade6b Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:56:31 +0200 Subject: [PATCH 07/12] chore: extend .gitignore (venv, trace.zip) --- .gitignore | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 From 2455f5c5a7c99b8b5ce5b1545bab52fc868957a6 Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:56:31 +0200 Subject: [PATCH 08/12] docs: reflect cross-browser CI, xdist, typed data, selector field note --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2fd8190..21f1b9e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ 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 @@ -32,6 +33,12 @@ Playwright (Chromium) · pytest · Page Object Model · Faker · pytest-html · pip install -r requirements.txt playwright install chromium 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. From 636c76ceefb802707301b44cf1dcd0b87013a2e5 Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 14:59:29 +0200 Subject: [PATCH 09/12] ci: run e2e suite on every pull request Adds the pull_request trigger so the full cross-browser matrix runs as required status checks on each PR, not just on push. --- .github/workflows/tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 75028c2..dd1ec41 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,9 @@ name: e2e-tests -on: [push, workflow_dispatch] +on: + push: + pull_request: + workflow_dispatch: jobs: test: From f1a9334e2b8c2926d3d59e4bb15b534e5dbcf33f Mon Sep 17 00:00:00 2001 From: Mykyta Dudarenko <67206438+larelgit@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:11:14 +0200 Subject: [PATCH 10/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index 5dcf931..ec594d6 100644 --- a/conftest.py +++ b/conftest.py @@ -12,7 +12,7 @@ r"(googlesyndication|doubleclick|adservice|google-analytics|googletagmanager|fundingchoices)" ) -# pytest-base-url reads `base_url` from pytest.ini, but that value is NOT +# 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") From d5567042e9e128a28f248762bca78959f5d76588 Mon Sep 17 00:00:00 2001 From: Mykyta Dudarenko <67206438+larelgit@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:11:28 +0200 Subject: [PATCH 11/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 21f1b9e..0265d7a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pytest-html · pytest-xdist · GitHub Actions ```bash pip install -r requirements.txt -playwright install chromium +playwright install pytest --html=report.html --self-contained-html # parallel (data isolation supports it) From b1415d1c9e5188ec13318f2945663fea491c359b Mon Sep 17 00:00:00 2001 From: larel Date: Wed, 17 Jun 2026 15:12:35 +0200 Subject: [PATCH 12/12] docs: add Copilot custom instructions for smarter PR review Repo-specific guidance: layout, run commands, selector/POM/typing conventions, and what to flag in review (sleep, XPath, text selectors, loosened pins, order-dependent tests). --- .github/copilot-instructions.md | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/copilot-instructions.md 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.