name: e2e-testing description: E2E testing conventions for Protocol Designer and Labware Library using Playwright + pytest in e2e-testing/. Use when writing, running, or modifying end-to-end tests, page objects, or Playwright tests.
E2E Testing Instructions
Project Overview
The e2e-testing directory contains end-to-end tests for Protocol Designer (PD) and Labware Library (LL) using:
- Playwright — Browser automation (Chromium)
- pytest — Test framework
- pytest-playwright — Pytest + Playwright integration
- uv — Python package manager (lockfile:
uv.lock) - Python 3.12
Directory Layout
automation/base_page.py— SharedBasePageclass inherited by all page objectsautomation/pd_pages/— PD page objects (import fromautomation.pd_pages)automation/ll_pages/— LL page objects (import fromautomation.ll_pages)tests/pd/— PD E2E tests (marked@pytest.mark.pdE2E)tests/ll/— LL E2E tests (marked@pytest.mark.llE2E)fixtures/— Protocol JSON files, labware definitions, and test data
Architecture — Page Object Model
ALWAYS use Page Object Model and/or Screenplay Pattern when writing or modifying tests.
Shared Base (automation/base_page.py)
BasePage provides: click_button, click_test_id, fill_input, wait_for_visible, dismiss_release_notes_toast, highlight_element, goto. Exposes self.is_sandbox (True when TEST_ENV=sandbox). All page objects inherit from it.
PD Page Objects (automation/pd_pages/)
LandingPage, CreateProtocolWizard, PipetteModal, ModuleConfigPage, DeckConfigPage, ProtocolEditorPage, TransferPage, MixStepForm, ThermocyclerStepPage, ThermocyclerProfileModal, TemperatureStepPage, HeaterShakerStepPage, PlateReaderPage, FlexStackerPage, SettingsPage, Timeline.
LL Page Objects (automation/ll_pages/)
DesktopNavigation, LabwareCreator.
Tests (tests/pd/ and tests/ll/)
- Import and use page objects — never write raw Playwright selectors in test files
- Naming:
test_<feature>_<scenario> - Mark PD tests with
@pytest.mark.pdE2E, LL tests with@pytest.mark.llE2E - Add
@pytest.mark.slowfor tests taking >10 seconds - Add type annotations:
def test_name(page: Page, pd_base_url: str) -> None:
Example — PD Page Object
"""Module for <page name> interactions."""
from playwright.sync_api import Page
from automation.base_page import BasePage
class MyPage(BasePage):
"""Page object for <page name>."""
def __init__(self, page: Page) -> None:
super().__init__(page)
def click_submit_button(self) -> None:
"""Click the submit button."""
self.click_button("Submit")
def fill_protocol_name(self, name: str) -> None:
"""Fill in the protocol name field."""
self.fill_input("protocolName", name)
Example — PD Test
import pytest
from playwright.sync_api import Page
from automation.pd_pages import LandingPage, MyPage
@pytest.mark.pdE2E
def test_my_feature(page: Page, pd_base_url: str) -> None:
"""Test description here."""
landing = LandingPage(page)
landing.wait_for_page_load()
my_page = MyPage(page)
my_page.fill_protocol_name("Test Protocol")
my_page.click_submit_button()
Example — LL Test
import pytest
from playwright.sync_api import Page
from automation.ll_pages import DesktopNavigation
@pytest.mark.llE2E
def test_nav_loads(page: Page, ll_base_url: str) -> None:
"""Verify navigation renders."""
page.goto(f"{ll_base_url}/")
nav = DesktopNavigation(page)
nav.wait_for_loaded()
Environment Configuration
Tests run against different environments via TEST_ENV:
- local (default): Auto-builds and serves the app (PD on ports 4173-4175, LL on ports 4176-4178)
- staging: PD →
https://staging.designer.opentrons.com/ LL →https://staging.labware.opentrons.com - prod: PD →
https://designer.opentrons.com/ LL →https://labware.opentrons.com - sandbox: TODO — Not implemented (requires branch-specific URLs)
conftest.py Fixtures
| Fixture | Scope | Purpose |
|---|---|---|
pd_base_url | session | Resolves PD URL; starts local preview server when TEST_ENV=local |
ll_base_url | session | Resolves LL URL; starts local preview server when TEST_ENV=local |
page | function | Creates a Playwright page, navigates to the correct app URL based on test markers, saves video |
browser_context_args | session | Viewport 1280x720, video recording |
browser_type_launch_args | session | Headless/headed, slow_mo |
eyes | function | Applitools Eyes session (or None) |
eyes_singleton | session | Shared Applitools Eyes instance |
Key Environment Variables
| Variable | Default | Notes |
|---|---|---|
TEST_ENV | local | local, staging, prod, sandbox |
HEADLESS | (unset) | true / false; overrides default |
SKIP_SERVER_START | false | Skip automatic server build+serve |
PD_SERVER_URL | auto | Override PD URL |
LL_SERVER_URL | auto | Override LL URL |
LL_SERVER_PORT | 4176 | Preferred port for LL local server |
APPLITOOLS_API_KEY | (unset) | Enable Applitools visual checks |
Development Commands
ALWAYS run before committing:
make format # Auto-format (ruff format + ruff check --fix)
make typecheck # Run mypy
make check # lint + typecheck combined
make prep # format + typecheck
Running PD tests:
make test-pd-local # Headless, chromium
make test-pd-local-headed # Headed, 250ms slow-mo
make test-pd-local PYTEST_ARGS="-k test_name" # Run one test
make test-pd-staging # Against staging
make test-pd-prod # Against prod
make test-pd-debug # Headed, 1000ms slow-mo, verbose
Running LL tests:
make test-ll-local # Headless, chromium
make test-ll-local-headed # Headed
make test-ll-staging # Against staging
make test-ll-prod # Against prod
Other targets:
make test-unit # Unit tests only
make troubleshoot # Re-run last failures in headed mode
make codegen # Playwright Inspector/recorder (default localhost:4173)
make codegen URL=<url> # Record against custom URL
Code Quality Standards
Type Annotations (REQUIRED)
All functions must have type annotations:
# Good
def my_function(page: Page, name: str) -> None:
"""Docstring here."""
pass
# Bad — Missing type annotations
def my_function(page, name):
pass
Note: mypy is strict for automation/ but relaxed (disallow_untyped_defs = false) for tests/.
Imports
Import page objects from the package, not from internal modules:
# Good
from automation.pd_pages import LandingPage
from automation.ll_pages import LabwareCreator
from automation.base_page import BasePage
# Bad
from automation.pd_pages.landing_page import LandingPage
Docstrings
ALWAYS add docstrings to modules (top of file), classes, and public methods.
Testing Best Practices
Selectors (prefer in order)
get_by_role()— Semantic HTML rolesget_by_test_id()— Test IDsget_by_text()— Visible textget_by_label()— Form labels
Avoid CSS selectors (brittle) and XPath (hard to maintain).
Wait for Elements
ALWAYS wait before interacting:
# Good — Using BasePage helper
self.wait_for_visible(element, timeout=5000)
# Good — Using Playwright expect
from playwright.sync_api import expect
expect(element).to_be_visible()
element.click()
# Bad — No wait
self.page.get_by_role("button", name="Submit").click()
Test Independence
- Don't rely on test execution order
- Don't share state between tests
- Clean up handled by fixtures and fresh browser contexts
Test Markers
Every PD test must have @pytest.mark.pdE2E. Every LL test must have @pytest.mark.llE2E. A test cannot have both markers.
Visual Snapshots (Applitools Eyes)
- Use the
eyespytest fixture (exposed viapytest_plugins = ["eyes"]inconftest.py) python-dotenvloads.env. SetAPPLITOOLS_API_KEYto enable- In headed mode,
eyesyieldsNone(no visual snapshots)
from eyes import Eyes
def test_my_feature(page: Page, pd_base_url: str, eyes: Eyes | None) -> None:
# ... navigate ...
if eyes is None:
return
eyes.check("After navigation")
eyes.check_element("Timeline", page.get_by_test_id("TimelineToolbox_scrollContainer"))
Common Patterns
Adding a New PD Test
- Create/update page objects in
automation/pd_pages/ - Write test in
tests/pd/using page objects, add@pytest.mark.pdE2E - Add type annotations and docstrings
- Run locally:
make test-pd-local PYTEST_ARGS="-k test_name" - Check code quality:
make check
Adding a New LL Test
- Create/update page objects in
automation/ll_pages/ - Write test in
tests/ll/using page objects, add@pytest.mark.llE2E - Add type annotations and docstrings
- Run locally:
make test-ll-local PYTEST_ARGS="-k test_name" - Check code quality:
make check
Chaining Page Objects
def navigate_and_configure(self) -> "NextPage":
"""Navigate to next page and return its page object."""
self.click_button("Next")
from .next_page import NextPage
return NextPage(self.page)
CI/CD Integration
.github/workflows/pd-e2e-test.yaml— PD E2E tests.github/workflows/ll-e2e-test.yaml— LL E2E tests.github/workflows/e2e-test-checks.yaml— Lint + typecheck
Troubleshooting
Local Server Issues
- Check Node.js version:
node --version(should be >= 22.22.0) - Build manually:
cd ../protocol-designer && make buildorcd ../labware-library && make build - Check memory: PD Makefile sets
NODE_OPTIONS=--max-old-space-size=8192 - Kill conflicting processes:
pkill -9 node
Import Errors
- Run:
make setup - Verify:
uv run python -c "from automation.base_page import BasePage; print('OK')"
Test Timeouts
Default: 300 seconds per test (set in pytest.ini). Per-test override: @pytest.mark.timeout(600). Use make test-pd-debug or make troubleshoot for step-by-step debugging.
DO NOT
- Write Playwright selectors directly in test files — USE page objects
- Use CSS selectors without justification — USE semantic selectors
- Commit without running
make check - Skip type annotations — REQUIRED by mypy
- Rely on test execution order — tests must be independent
- Use
time.sleep()— use Playwright's waiting mechanisms - Mark a test with both
@pytest.mark.pdE2Eand@pytest.mark.llE2E - Import
BasePagefromautomation.pd_pages.base_page— useautomation.base_page