diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..8c2f42b --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,53 @@ +name: Playwright Examples + +on: + push: + branches: + - main + paths: + - 'examples/playwright/**' + - '.github/workflows/playwright.yml' + pull_request: + branches: + - main + paths: + - 'examples/playwright/**' + - '.github/workflows/playwright.yml' + schedule: + - cron: "0 9 * * *" + +jobs: + python-tests: + name: Tests + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + pull-requests: write + defaults: + run: + working-directory: examples/playwright + + steps: + - uses: actions/checkout@v7 + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: 3.14 + - name: Install pipenv + uses: dschep/install-pipenv-action@v1 + - name: Install dependencies + run: pipenv install + - name: Run Playwright examples + env: + TARGET: sauce + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + run: pipenv run playwright-tests --junitxml=test-results.xml + - name: Publish test report + uses: dorny/test-reporter@v3 + if: always() + with: + name: Playwright Examples Test Report + path: 'examples/playwright/test-results.xml' + reporter: python-xunit diff --git a/examples/playwright/Pipfile b/examples/playwright/Pipfile index e3bef7c..ea7d9f0 100644 --- a/examples/playwright/Pipfile +++ b/examples/playwright/Pipfile @@ -6,6 +6,6 @@ playwright = "==1.61.0" requests = "==2.34.2" [scripts] -playwright-tests = "pytest -n8 ." +playwright-tests = "pytest -n8 --dist=loadfile ." [dev-packages] diff --git a/examples/playwright/README.md b/examples/playwright/README.md new file mode 100644 index 0000000..2eaa6b0 --- /dev/null +++ b/examples/playwright/README.md @@ -0,0 +1,131 @@ +# Playwright Examples + +This directory contains Playwright test examples for the [Sauce Demo](https://www.saucedemo.com/) +website using Python and pytest (via the [`pytest-playwright`](https://github.com/microsoft/playwright-python) +plugin). Tests run either against a **locally launched browser** (the default) or on **Sauce Labs** +via its native Playwright WebSocket endpoint - switching between them is a single environment +variable, and everything else about the test (browser engine, context/page isolation, native +pytest-playwright flags like `--headed`, `--tracing`, `--screenshot`) behaves identically either way. + +## Overview + +`TARGET` picks where the browser lives: +- `TARGET` unset or `local` (default): launches a browser on the machine running the tests, via + Playwright's own `browser_type.launch()`. No Sauce Labs account needed. +- `TARGET=sauce`: connects to Sauce Labs' native Playwright WebSocket endpoint instead - creates a + native Playwright session on Sauce Labs, connects via `browser_type.connect()` to the returned + WebSocket endpoint, and reports the test result back to Sauce Labs. + +Browser engine selection is **not** a custom environment variable - it's `pytest-playwright`'s own +native `--browser` flag (defaults to `chromium`), so it means the same thing for both targets: +`pytest --browser=firefox` runs Firefox whether you're local or on Sauce. Because only the +`browser` fixture itself is overridden here (not `context`/`page`), every other native +`pytest-playwright` flag also works unchanged for both targets - `--headed`, `--slowmo`, +`--browser-channel`, `--tracing`, `--video`, `--screenshot`, the `browser_context_args` marker, +`skip_browser`/`only_browser` markers, and running multiple engines in one invocation +(`--browser=chromium --browser=firefox`). + +`GROUPING` picks how sessions are shared across tests: +- `GROUPING` unset or `module` (default): one session per test **file**, named after the file. + Every test in that file reuses the same browser/Sauce session in turn. +- `GROUPING=test`: one dedicated, never-shared session per test, named after the test itself. + +### Session lifecycle + +A session (local browser or Sauce Labs job) is opened for a test and, on teardown, either kept +alive for reuse by the next test in its group (test passed) or closed immediately (test failed) - +so a failing test never leaves a possibly-dirty browser behind for whatever test picks up that +session next. Any sessions still open once the whole run finishes are closed out automatically. +This applies the same way regardless of `TARGET` or `GROUPING`. + +## Prerequisites + +- Python 3 and `pipenv` +- Playwright browser binaries: `pipenv run playwright install` +- For `TARGET=sauce`: a Sauce Labs account, with `SAUCE_USERNAME` and `SAUCE_ACCESS_KEY` + environment variables set + +## Running Tests + +Run all tests locally (default), via the Pipenv script: +```bash +pipenv run playwright-tests +``` + +Or directly with pytest from the repo root: +```bash +pytest examples/playwright/ +``` + +Run all tests on Sauce Labs: +```bash +TARGET=sauce pytest examples/playwright/ +``` + +Run locally with a specific browser engine (native pytest-playwright flag): +```bash +pytest --browser=firefox examples/playwright/ +``` + +Run with one session per test instead of one per file: +```bash +GROUPING=test pytest examples/playwright/ +``` + +Run headed, with tracing and screenshots on failure (native pytest-playwright flags, work with +either `TARGET`): +```bash +pytest --headed --tracing=on --screenshot=only-on-failure examples/playwright/ +``` + +Run in parallel (the Pipenv script already does this): +```bash +pytest -n8 --dist=loadfile examples/playwright/ +``` +`--dist=loadfile` is required, not optional, for `GROUPING=module` reuse to actually happen under +`pytest-xdist` - its default scheduling can otherwise split one file's tests across two worker +processes, silently defeating reuse (each worker would open its own separate session for the same +file). `--dist=loadfile` guarantees a file's tests always land on the same worker. + +## Viewing Test Results + +When `TARGET=sauce`, you can view the results in the [Sauce Labs Dashboard](https://app.saucelabs.com/). +The console output includes a direct link to each job: + +``` +SauceOnDemandSessionID= +Test Job Link: https://app.saucelabs.com/tests/ +``` + +## Project Structure + +``` +examples/playwright/ +├── README.md +├── conftest.py # TARGET/GROUPING logic, overrides the `browser` fixture only +├── test_login.py +├── test_cart.py +├── test_checkout.py +├── test_navigation.py +├── test_sorting.py +└── test_inventory.py +``` + +## Configuration + +| Variable | Default | Description | +|----------|---------|--------------| +| `TARGET` | `local` | Where the browser runs: `local` or `sauce` | +| `GROUPING` | `module` | Session sharing: `module` (one session per test file) or `test` (one per test) | +| `SAUCE_REGION` | `us-west-1` | Data center for all Sauce Labs URLs (e.g. `us-east-1`, `eu-central-1`). Only used when `TARGET=sauce` | +| `SAUCE_BUILD_NAME` | `Playwright Python_` | Build name shown on the Sauce Labs dashboard. Only used when `TARGET=sauce` | +| `SAUCE_USERNAME` / `SAUCE_ACCESS_KEY` | - | Sauce Labs credentials. Only used when `TARGET=sauce` | + +Browser engine, headless/headed mode, tracing, video, and screenshots are all controlled by +`pytest-playwright`'s own native flags (`--browser`, `--headed`, `--slowmo`, `--browser-channel`, +`--tracing`, `--video`, `--screenshot`) rather than custom environment variables - see +[`pytest-playwright`'s documentation](https://github.com/microsoft/playwright-python#pytest-plugin) +for the full list. + +Note: currently only `platformName: "Linux"` is supported by Sauce Labs' native Playwright +WebSocket endpoint. diff --git a/examples/playwright/conftest.py b/examples/playwright/conftest.py index 73c867c..6b454b6 100644 --- a/examples/playwright/conftest.py +++ b/examples/playwright/conftest.py @@ -1,78 +1,203 @@ import base64 +import importlib.metadata import os +from datetime import datetime + import pytest import requests -from playwright.sync_api import sync_playwright -from datetime import datetime SAUCE_DEMO_URL = "https://www.saucedemo.com/" -SAUCE_USERNAME = os.environ.get("SAUCE_USERNAME") -SAUCE_ACCESS_KEY = os.environ.get("SAUCE_ACCESS_KEY") -SAUCE_BUILD_NAME = "Playwright" + datetime.now().strftime("_%Y%m%d_%H%M%S") -# Update job status on Sauce Labs using requests -def update_job_status(session_id, status): - url = f"https://api.us-west-1.saucelabs.com/rest/v1/{SAUCE_USERNAME}/jobs/{session_id}" - auth = base64.b64encode(f"{SAUCE_USERNAME}:{SAUCE_ACCESS_KEY}".encode()).decode() - headers = { - "Authorization": f"Basic {auth}", - "Content-Type": "application/json" - } - data = {"passed": status == "passed"} - requests.put(url, headers=headers, json=data) - - -@pytest.fixture(scope="function") -def browser(): - with sync_playwright() as p: - browser = p.chromium.launch(headless=False) - context = browser.new_context() - page = context.new_page() - yield page - context.close() - browser.close() - -@pytest.fixture(scope="function") -def remote_browser(request): - test_name = request.node.name - endpoint = "https://ondemand.us-west-1.saucelabs.com/wd/hub/session" - payload = { - "capabilities": { - "alwaysMatch": { - "platformName": "Windows 11", - "browserName": "Chrome", - "sauce:options": { - "username": SAUCE_USERNAME, - "accessKey": SAUCE_ACCESS_KEY, - "devTools": True, - "_tptCommanderVersion": "stable", - "name": test_name, - "build": SAUCE_BUILD_NAME, - }, - "goog:chromeOptions": { - "args": [ - "--no-sandbox", - "--disable-infobars", - "--disable-features=SafeBrowsing,PasswordLeakToggleMove", - ] - } - } - } +SAUCE_USERNAME = os.environ.get("SAUCE_USERNAME", "") +SAUCE_ACCESS_KEY = os.environ.get("SAUCE_ACCESS_KEY", "") +SAUCE_REGION = os.environ.get("SAUCE_REGION", "us-west-1") +SAUCE_URL = f"https://ondemand.{SAUCE_REGION}.saucelabs.com" +SAUCE_API_URL = f"https://api.{SAUCE_REGION}.saucelabs.com" +SAUCE_BUILD_NAME = os.environ.get( + "SAUCE_BUILD_NAME", "Playwright Python" + datetime.now().strftime("_%Y%m%d_%H%M%S") +) + +# TARGET selects whether a session is a locally launched browser or a Sauce Labs one - anything +# other than "sauce" defaults to local. Browser engine selection is NOT our own env var - it's +# pytest-playwright's native `--browser` flag (browser_type fixture below), so `--headed`, +# `--slowmo`, `--browser-channel` etc. all apply the same way regardless of TARGET. +TARGET = os.environ.get("TARGET", "local").strip().lower() + +# GROUPING picks how sessions are shared across tests - anything other than "test" defaults to +# "module" (one session per test file, named after the file) - the natural grouping unit here, +# since these test files are plain functions, not classes. +# "test": one dedicated, never-shared session per test, named after the test itself. +GROUPING = os.environ.get("GROUPING", "module").strip().lower() + + +def pytest_configure(config): + # Only present on xdist worker processes - overrides this worker's own independently-computed + # SAUCE_BUILD_NAME with the single value the controller assigned it via pytest_configure_node. + global SAUCE_BUILD_NAME + if hasattr(config, "workerinput"): + SAUCE_BUILD_NAME = config.workerinput["sauce_build_name"] + + +def pytest_configure_node(node): + # xdist controller only - runs once per worker being spawned, before it starts. Stashes this + # run's build name so every worker picks up the exact same value in pytest_configure above. + node.workerinput["sauce_build_name"] = SAUCE_BUILD_NAME + + +def _playwright_version(): + # importlib.metadata reads the actually-installed package version (matches `pip show + # playwright`) rather than an attribute baked into the package at build time - verified + # empirically against a stale-value bug found while building this. + version = importlib.metadata.version("playwright") + major, minor = version.split(".")[:2] + return f"{major}.{minor}" + + +PLAYWRIGHT_VERSION = _playwright_version() + + +class WorkerSession: + def __init__(self, browser, session_id=None): + self.browser = browser + self.session_id = session_id + + +# "module" mode only: one entry per (test module, browser engine), holding that combination's +# shared session - keyed on browser engine too since --browser can be passed more than once to +# parametrize a single run across engines. Plain dict is safe without locking because pytest runs +# tests within one worker process sequentially, never concurrently. Under pytest-xdist each worker +# is a separate process with its own copy of this dict, so reuse only happens for tests that land +# on the same worker (see the "playwright-tests" Pipfile script, which uses --dist=loadfile to +# guarantee that for same-module tests). +_module_sessions = {} + + +def _build_capabilities_payload(session_name, browser_name): + return { + "browserName": browser_name, + "platformName": "Linux", + "playwrightVersion": PLAYWRIGHT_VERSION, + "sauce:options": { + "name": session_name, + "build": SAUCE_BUILD_NAME, + }, } - response = requests.post(endpoint, json=payload, auth=(SAUCE_USERNAME, SAUCE_ACCESS_KEY)) + + +def _open_sauce_session(browser_type, session_name): + browser_name = browser_type.name + auth = (SAUCE_USERNAME, SAUCE_ACCESS_KEY) + # The endpoint 303-redirects while the VM spins up; follow until we get a 200. + response = requests.post( + f"{SAUCE_URL}/playwright/session", + json=_build_capabilities_payload(session_name, browser_name), + auth=auth, + allow_redirects=False, + timeout=120, + ) + while response.status_code == 303: + location = response.headers.get("location") + if not location: + raise RuntimeError( + f"Sauce responded {response.status_code} to POST /playwright/session " + "without a Location header." + ) + if not location.startswith("http"): + location = f"{SAUCE_URL}/{location.lstrip('/')}" + response = requests.get(location, auth=auth, allow_redirects=False, timeout=120) + response.raise_for_status() - session_data = response.json() - session_id = session_data["value"]["sessionId"] - delete_endpoint = f"https://ondemand.us-west-1.saucelabs.com/wd/hub/session/{session_id}" - cdp_endpoint = session_data["value"]["capabilities"].get("se:cdp") - with sync_playwright() as p: - browser = p.chromium.connect_over_cdp(cdp_endpoint) - context = browser.contexts[0] if browser.contexts else browser.new_context() - page = context.new_page() - yield page - context.close() - browser.close() - requests.delete(delete_endpoint, auth=(SAUCE_USERNAME, SAUCE_ACCESS_KEY)) - sauce_result = "failed" if request.session.testsfailed == 1 else "passed" - update_job_status(session_id, sauce_result) + body = response.json() + value = body.get("value", body) + session_id = value["sessionId"] + ws_endpoint = value["wsEndpoint"] + + browser = browser_type.connect(f"{ws_endpoint}?browser={browser_name}") + return WorkerSession(browser, session_id) + + +def _open_local_session(browser_type, launch_args): + # Native launch_browser (pytest_playwright.py) does the same launch(**launch_args) call - this + # is what makes --headed/--slowmo/--browser-channel apply here exactly as they would natively. + browser = browser_type.launch(**launch_args) + return WorkerSession(browser) + + +def _open_session(browser_type, launch_args, session_name): + if TARGET == "sauce": + return _open_sauce_session(browser_type, session_name) + return _open_local_session(browser_type, launch_args) + + +def _update_sauce_result(session_id, passed): + url = f"{SAUCE_API_URL}/rest/v1/{SAUCE_USERNAME}/jobs/{session_id}" + auth = base64.b64encode(f"{SAUCE_USERNAME}:{SAUCE_ACCESS_KEY}".encode()).decode() + headers = {"Authorization": f"Basic {auth}", "Content-Type": "application/json"} + requests.put(url, headers=headers, json={"passed": passed}, timeout=30) + + +def _close_session(session, passed): + try: + if session.session_id: + _update_sauce_result(session.session_id, passed) + print(f"SauceOnDemandSessionID={session.session_id}") + print(f"Test Job Link: https://app.saucelabs.com/tests/{session.session_id}") + # The Sauce session ends when its WebSocket connection drops; a local browser just closes. + session.browser.close() + except Exception as exc: + print(f"Error closing session {session.session_id}: {exc}") + + +def _test_passed(request): + report = getattr(request.node, "rep_call", None) + return bool(report and report.passed) + + +@pytest.fixture +def browser(request, browser_type, browser_type_launch_args): + # Overrides pytest-playwright's own `browser` fixture. Everything above it in the native chain + # (new_context/context/page, browser_context_args marker, --screenshot/--video/--tracing + # artifacts) is untouched and works against this Browser exactly as it would against a locally + # launched one - only *how* the Browser is obtained changes with TARGET. This has to stay + # function-scoped (not module-scoped) regardless of GROUPING: module-scoped teardown only runs + # once, at the very end of the module, which can't express "evict immediately on failure" - + # that needs to be checked after every single test. + browser_name = browser_type.name + + if GROUPING == "test": + session = _open_session(browser_type, browser_type_launch_args, request.node.name) + else: + module_name = request.module.__name__.rsplit(".", 1)[-1] + cache_key = (module_name, browser_name) + session = _module_sessions.get(cache_key) + if session is None: + session = _open_session(browser_type, browser_type_launch_args, module_name) + _module_sessions[cache_key] = session + + yield session.browser + + passed = _test_passed(request) + + if GROUPING == "test": + # Never shared, so there's nobody to hand this off to - always close and report, pass or fail. + _close_session(session, passed) + elif not passed: + # Passed: leave the session for the next test in this module to reuse. Failed: evict and + # close it now instead of leaving a possibly-dirty browser for the next test to inherit. + _module_sessions.pop((module_name, browser_name), None) + _close_session(session, passed=False) + +@pytest.fixture(scope="session", autouse=True) +def _close_leftover_module_sessions(playwright): + # Depending on `playwright` (native, session-scoped) isn't for its value - it's what makes + # pytest tear this fixture down *before* the driver itself stops (fixture teardown is LIFO by + # setup order). Without that dependency this ran after the driver had already stopped, and + # browser.close() failed with "Event loop is closed" - confirmed empirically. + yield + # Closes every session still in _module_sessions ("test" mode never populates it - each session + # is already closed in the browser fixture). Any module session that ever failed a test was + # already evicted and closed there, so everything left here only ever ran passing tests. + for module_session in _module_sessions.values(): + _close_session(module_session, passed=True) + _module_sessions.clear() diff --git a/examples/playwright/test_cart.py b/examples/playwright/test_cart.py new file mode 100644 index 0000000..fdc97b1 --- /dev/null +++ b/examples/playwright/test_cart.py @@ -0,0 +1,49 @@ +SAUCE_DEMO_URL = "https://www.saucedemo.com/" + +def login(page, username="standard_user", password="secret_sauce"): + page.goto(SAUCE_DEMO_URL) + page.fill('input[data-test="username"]', username) + page.fill('input[data-test="password"]', password) + page.click('input[data-test="login-button"]') + +def test_add_item_to_cart_from_inventory(page): + login(page) + + page.click('[data-test="add-to-cart-sauce-labs-backpack"]') + + assert page.locator('.shopping_cart_badge').inner_text() == "1" + +def test_add_multiple_items_to_cart(page): + login(page) + + page.click('[data-test="add-to-cart-sauce-labs-backpack"]') + page.click('[data-test="add-to-cart-sauce-labs-bolt-t-shirt"]') + page.click('[data-test="add-to-cart-sauce-labs-onesie"]') + + assert page.locator('.shopping_cart_badge').inner_text() == "3" + +def test_remove_item_from_inventory(page): + login(page) + + page.click('[data-test="add-to-cart-sauce-labs-backpack"]') + page.click('[data-test="remove-sauce-labs-backpack"]') + + assert page.locator('.shopping_cart_badge').count() == 0 + +def test_remove_item_from_cart(page): + login(page) + + page.click('[data-test="add-to-cart-sauce-labs-backpack"]') + page.click('.shopping_cart_link') + page.click('[data-test="remove-sauce-labs-backpack"]') + + assert page.locator('.shopping_cart_badge').count() == 0 + +def test_continue_shopping_from_cart(page): + login(page) + + page.click('[data-test="add-to-cart-sauce-labs-backpack"]') + page.click('.shopping_cart_link') + page.click('[data-test="continue-shopping"]') + + assert page.url == SAUCE_DEMO_URL + "inventory.html" diff --git a/examples/playwright/test_checkout.py b/examples/playwright/test_checkout.py new file mode 100644 index 0000000..d6e2e71 --- /dev/null +++ b/examples/playwright/test_checkout.py @@ -0,0 +1,68 @@ +SAUCE_DEMO_URL = "https://www.saucedemo.com/" + +def login(page, username="standard_user", password="secret_sauce"): + page.goto(SAUCE_DEMO_URL) + page.fill('input[data-test="username"]', username) + page.fill('input[data-test="password"]', password) + page.click('input[data-test="login-button"]') + +def add_backpack_and_go_to_checkout(page): + page.click('[data-test="add-to-cart-sauce-labs-backpack"]') + page.click('.shopping_cart_link') + page.click('[data-test="checkout"]') + +def test_complete_checkout(page): + login(page) + add_backpack_and_go_to_checkout(page) + + page.fill('[data-test="firstName"]', "John") + page.fill('[data-test="lastName"]', "Doe") + page.fill('[data-test="postalCode"]', "12345") + page.click('[data-test="continue"]') + + assert page.url == SAUCE_DEMO_URL + "checkout-step-two.html" + + page.click('[data-test="finish"]') + + assert page.locator('.complete-header').inner_text() == "Thank you for your order!" + +def test_checkout_with_missing_first_name(page): + login(page) + add_backpack_and_go_to_checkout(page) + + # Leave first name empty + page.fill('[data-test="lastName"]', "Doe") + page.fill('[data-test="postalCode"]', "12345") + page.click('[data-test="continue"]') + + assert "Error: First Name is required" in page.locator('[data-test="error"]').inner_text() + +def test_checkout_with_missing_last_name(page): + login(page) + add_backpack_and_go_to_checkout(page) + + page.fill('[data-test="firstName"]', "John") + # Leave last name empty + page.fill('[data-test="postalCode"]', "12345") + page.click('[data-test="continue"]') + + assert "Error: Last Name is required" in page.locator('[data-test="error"]').inner_text() + +def test_checkout_with_missing_postal_code(page): + login(page) + add_backpack_and_go_to_checkout(page) + + page.fill('[data-test="firstName"]', "John") + page.fill('[data-test="lastName"]', "Doe") + # Leave postal code empty + page.click('[data-test="continue"]') + + assert "Error: Postal Code is required" in page.locator('[data-test="error"]').inner_text() + +def test_cancel_checkout(page): + login(page) + add_backpack_and_go_to_checkout(page) + + page.click('[data-test="cancel"]') + + assert page.url == SAUCE_DEMO_URL + "cart.html" diff --git a/examples/playwright/test_inventory.py b/examples/playwright/test_inventory.py index 9f2c988..23fd83f 100644 --- a/examples/playwright/test_inventory.py +++ b/examples/playwright/test_inventory.py @@ -6,9 +6,8 @@ def login(page, username, password): page.fill('input[data-test="password"]', password) page.click('input[data-test="login-button"]') -def test_inventory_page_loads(remote_browser): - login(remote_browser, 'standard_user', 'secret_sauce') - assert remote_browser.url.endswith("/inventory.html") - assert remote_browser.locator('.inventory_list').is_visible() - assert remote_browser.locator('.inventory_item').count() > 0 - +def test_inventory_page_loads(page): + login(page, 'standard_user', 'secret_sauce') + assert page.url.endswith("/inventory.html") + assert page.locator('.inventory_list').is_visible() + assert page.locator('.inventory_item').count() > 0 diff --git a/examples/playwright/test_login.py b/examples/playwright/test_login.py index 1bd72fb..596c53f 100644 --- a/examples/playwright/test_login.py +++ b/examples/playwright/test_login.py @@ -1,16 +1,27 @@ SAUCE_DEMO_URL = "https://www.saucedemo.com/" -def test_login_valid(remote_browser): - remote_browser.goto(SAUCE_DEMO_URL) - remote_browser.fill('input[data-test="username"]', 'standard_user') - remote_browser.fill('input[data-test="password"]', 'secret_sauce') - remote_browser.click('input[data-test="login-button"]') - assert remote_browser.url.endswith("/inventory.html") +def login(page, username, password): + page.goto(SAUCE_DEMO_URL) + page.fill('input[data-test="username"]', username) + page.fill('input[data-test="password"]', password) + page.click('input[data-test="login-button"]') -def test_login_invalid(remote_browser): - remote_browser.goto(SAUCE_DEMO_URL) - remote_browser.fill('input[data-test="username"]', 'locked_out_user') - remote_browser.fill('input[data-test="password"]', 'wrong_password') - remote_browser.click('input[data-test="login-button"]') - assert remote_browser.locator('h3[data-test="error"]').is_visible() +def test_login_valid(page): + login(page, 'standard_user', 'secret_sauce') + assert page.url == SAUCE_DEMO_URL + "inventory.html" +def test_login_locked_out_user(page): + login(page, 'locked_out_user', 'secret_sauce') + assert "Sorry, this user has been locked out" in page.locator('[data-test="error"]').inner_text() + +def test_login_invalid_credentials(page): + login(page, 'invalid_user', 'invalid_password') + assert "Username and password do not match" in page.locator('[data-test="error"]').inner_text() + +def test_logout(page): + login(page, 'standard_user', 'secret_sauce') + + page.click('#react-burger-menu-btn') + page.click('#logout_sidebar_link') + + assert page.url == SAUCE_DEMO_URL diff --git a/examples/playwright/test_navigation.py b/examples/playwright/test_navigation.py new file mode 100644 index 0000000..09f55b8 --- /dev/null +++ b/examples/playwright/test_navigation.py @@ -0,0 +1,56 @@ +import re + +SAUCE_DEMO_URL = "https://www.saucedemo.com/" + +def login(page, username="standard_user", password="secret_sauce"): + page.goto(SAUCE_DEMO_URL) + page.fill('input[data-test="username"]', username) + page.fill('input[data-test="password"]', password) + page.click('input[data-test="login-button"]') + +def test_navigate_to_product_details(page): + login(page) + + page.click('[data-test="item-4-title-link"]') + + assert re.search(r"inventory-item\.html\?id=4", page.url) + assert page.locator('[data-test="inventory-item-name"]').inner_text() == "Sauce Labs Backpack" + +def test_navigate_back_to_products(page): + login(page) + + page.click('[data-test="item-4-title-link"]') + page.click('[data-test="back-to-products"]') + + assert page.url == SAUCE_DEMO_URL + "inventory.html" + +def test_navigate_to_cart(page): + login(page) + + page.click('.shopping_cart_link') + + assert page.url == SAUCE_DEMO_URL + "cart.html" + +def test_navigate_using_burger_menu(page): + login(page) + + page.click('#react-burger-menu-btn') + page.click('#about_sidebar_link') + + assert page.url == "https://saucelabs.com/" + +def test_reset_app_state(page): + login(page) + + # Add items to cart + page.click('[data-test="add-to-cart-sauce-labs-backpack"]') + page.click('[data-test="add-to-cart-sauce-labs-bolt-t-shirt"]') + + assert page.locator('.shopping_cart_badge').inner_text() == "2" + + # Reset app state + page.click('#react-burger-menu-btn') + page.click('#reset_sidebar_link') + + # Verify cart is empty + assert page.locator('.shopping_cart_badge').count() == 0 diff --git a/examples/playwright/test_sorting.py b/examples/playwright/test_sorting.py new file mode 100644 index 0000000..5a38452 --- /dev/null +++ b/examples/playwright/test_sorting.py @@ -0,0 +1,35 @@ +SAUCE_DEMO_URL = "https://www.saucedemo.com/" + +def login(page, username="standard_user", password="secret_sauce"): + page.goto(SAUCE_DEMO_URL) + page.fill('input[data-test="username"]', username) + page.fill('input[data-test="password"]', password) + page.click('input[data-test="login-button"]') + +def test_sort_by_name_a_to_z(page): + login(page) + + page.select_option('[data-test="product-sort-container"]', "az") + + assert page.locator('[data-test="inventory-item-name"]').first.inner_text() == "Sauce Labs Backpack" + +def test_sort_by_name_z_to_a(page): + login(page) + + page.select_option('[data-test="product-sort-container"]', "za") + + assert page.locator('[data-test="inventory-item-name"]').first.inner_text() == "Test.allTheThings() T-Shirt (Red)" + +def test_sort_by_price_low_to_high(page): + login(page) + + page.select_option('[data-test="product-sort-container"]', "lohi") + + assert page.locator('[data-test="inventory-item-price"]').first.inner_text() == "$7.99" + +def test_sort_by_price_high_to_low(page): + login(page) + + page.select_option('[data-test="product-sort-container"]', "hilo") + + assert page.locator('[data-test="inventory-item-price"]').first.inner_text() == "$49.99"