Skip to content

Migrate integration tests from Selenium to Playwright#6378

Open
masenf wants to merge 13 commits intomainfrom
claude/selenium-to-playwright-migration-7qluV
Open

Migrate integration tests from Selenium to Playwright#6378
masenf wants to merge 13 commits intomainfrom
claude/selenium-to-playwright-migration-7qluV

Conversation

@masenf
Copy link
Copy Markdown
Collaborator

@masenf masenf commented Apr 24, 2026

Type of change

  • Breaking change (removal of Selenium-based testing infrastructure)
  • This change requires a documentation update

Description

This PR migrates the integration test suite from Selenium WebDriver to Playwright, modernizing the browser automation framework used for testing. The changes include:

Key Changes:

  • Replaced all Selenium WebDriver imports and usage with Playwright's sync_api (Page, Locator, expect)
  • Updated AppHarness to remove Selenium-specific methods (frontend(), poll_for_value()) and WebDriver management
  • Converted test fixtures to use Playwright's built-in page fixture instead of custom WebDriver fixtures
  • Refactored element interaction patterns:
    • find_element(By.ID, "id")page.locator("#id")
    • element.send_keys()locator.fill()
    • element.click()locator.click()
    • driver.execute_script()page.evaluate()
    • driver.get_cookies()page.context.cookies()
    • driver.delete_all_cookies()page.context.clear_cookies()
  • Updated storage helper utilities (LocalStorage, SessionStorage) to work with Playwright pages
  • Added poll_for_token() utility function to replace Selenium-based token polling
  • Changed fixture scopes from function-level to module-level where appropriate for better test performance
  • Removed Selenium from pyproject.toml dependencies
  • Removed APP_HARNESS_HEADLESS environment variable from CI workflows (Playwright handles headless mode differently)

Files Modified:

  • reflex/testing.py: Removed Selenium WebDriver support and related infrastructure
  • tests/integration/utils.py: Updated storage helpers and added Playwright-compatible utilities
  • tests/integration/test_*.py: Updated all integration tests to use Playwright
  • .github/workflows/: Removed Selenium-specific environment variables
  • pyproject.toml: Removed Selenium dependency
  • CONTRIBUTING.md: Added integration test documentation

Testing

All integration tests have been updated and should pass with Playwright. The test suite now uses Playwright's native fixtures and APIs, which provide better performance and reliability compared to Selenium.

Migration Notes

Tests now rely on Playwright's built-in page fixture (provided by pytest-playwright). The fixture automatically manages browser lifecycle and context. Tests that previously created multiple WebDriver instances now use page.context.new_page() to create additional pages within the same browser context.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ

Every AppHarness-based integration test now uses pytest-playwright's `page`
fixture in place of Selenium's WebDriver.  The Selenium frontend/polling
helpers are removed from `reflex.testing.AppHarness`, the `selenium` dev
dependency is dropped, and `APP_HARNESS_HEADLESS`/`APP_HARNESS_DRIVER(_ARGS)`
env vars are deleted.  `tests_playwright/` is flattened back into
`tests/integration/`, and the CI workflow collapses its selenium + playwright
jobs into a single `uv run pytest tests/integration` invocation.

`tests/integration/utils.py` is rewritten for Playwright (`LocalStorage`,
`SessionStorage`, `poll_for_token`, `poll_for_navigation`,
`poll_assert_event_order`, `poll_assert_relative_event_order`) with the same
API surface as before so call sites only need a `page` instead of a `driver`.

AGENTS.md and CONTRIBUTING.md now document Playwright as the sole integration
framework and require `scope="module"` on every `AppHarness`-returning fixture.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
@masenf masenf requested a review from a team as a code owner April 24, 2026 00:05
…aywright-migration-7qluV

# Conflicts:
#	tests/integration/test_linked_state.py
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 24, 2026

Greptile Summary

This PR completes a full migration of the integration test suite from Selenium WebDriver to Playwright's sync API, removing selenium as a dependency and consolidating the previously split CI jobs into a single job with --splits 2 parallelism.

  • P1 — asyncio/playwright event-loop conflict: The old workflow explicitly separated Playwright tests into their own job because pytest-playwright keeps an asyncio event loop on the main thread for the entire session, which conflicts with pytest-asyncio. Several migrated tests are both async def (governed by asyncio_mode = "auto") and request the page fixture (e.g. test_client_side_state, test_connection_banner, test_upload_file_multiple). Merging the jobs without resolving this root cause may reproduce the original conflict in CI.
  • P2 — inconsistent file-name assertion in test_upload_file: The single-file variant dropped the Path().name normalization present in the multi-file tests, making it brittle if the display ever includes a directory prefix.

Confidence Score: 4/5

Mostly safe migration but the asyncio/playwright event-loop compatibility in the merged CI job should be confirmed before merging.

The Selenium-to-Playwright migration itself is thorough and correct; the P1 concern is specifically about whether pytest-asyncio and pytest-playwright can share an event loop in the same session, which the old code explicitly avoided via separate jobs. If the author confirms CI passes end-to-end, the score would move to 5.

.github/workflows/integration_app_harness.yml (removed job isolation), and any test file that is both async def and uses the page fixture (e.g. test_client_storage.py, test_upload.py, test_connection_banner.py).

Important Files Changed

Filename Overview
.github/workflows/integration_app_harness.yml Consolidated two jobs into one; the old split existed specifically to avoid an asyncio/playwright event-loop conflict that may still exist.
reflex/testing.py Removed Selenium WebDriver support cleanly; remaining AppHarness infrastructure is intact.
tests/integration/utils.py Storage helpers migrated to Playwright; poll_for_navigation uses custom polling instead of Playwright-native wait_for_url.
tests/integration/test_upload.py Migrated with correct set_input_files usage; single-file assertion dropped path-normalization present in multi-file variant.
tests/integration/test_client_storage.py Clean migration with correct cookie attribute adaptation (expiry->expires); fixes latent infinite-recursion in LocalStorage.has().
tests/integration/test_connection_banner.py Well-migrated; page.context.set_offline() is more reliable than the previous CDP approach.
tests/integration/test_dynamic_routes.py Navigation and dynamic-route assertions cleanly replaced with Playwright locators and expect assertions.
packages/reflex-base/src/reflex_base/environment.py Removed Selenium-specific env vars cleanly.
pyproject.toml Removed selenium from dev dependencies.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph OLD["Old CI Structure"]
        J1["integration-app-harness\n(Selenium tests)\npytest --ignore=tests_playwright"]
        J2["integration-app-harness-playwright\n(Playwright tests)\npytest tests_playwright/"]
    end
    subgraph NEW["New CI Structure"]
        J3["integration-app-harness\n(All tests: Playwright only)\npytest tests/integration --splits 2"]
        J3a["split group 1"]
        J3b["split group 2"]
        J3 --> J3a
        J3 --> J3b
    end
    subgraph CONCERN["Compatibility Concern"]
        PW["pytest-playwright\n(session-scoped event loop)"]
        PA["pytest-asyncio\nasyncio_mode=auto"]
        PW -. "potential conflict" .-> PA
    end
    OLD --> NEW
    J3 --> CONCERN
Loading

Comments Outside Diff (1)

  1. tests/integration/utils.py, line 51-55 (link)

    P2 Custom URL-change polling instead of Playwright-native wait_for_url

    poll_for_navigation still uses AppHarness.expect() (a custom sleep-poll loop inherited from Selenium) to detect URL changes. Playwright exposes page.wait_for_url(lambda url: url != prev_url, timeout=timeout * 1000) which hooks directly into Playwright's internal event system and is more reliable.

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +58 to +61
- name: Run app harness tests
env:
REFLEX_REDIS_URL: ${{ matrix.state_manager == 'redis' && 'redis://localhost:6379' || '' }}
run: uv run pytest tests/integration/tests_playwright --reruns 3 -v --maxfail=5
run: uv run pytest tests/integration --reruns 3 -v --maxfail=5 --splits 2 --group ${{matrix.split_index}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Removed job separation that guarded against asyncio/pytest-playwright event-loop conflict

The old workflow had an explicit comment and a separate CI job for Playwright tests because:

"The pytest-playwright plugin keeps an asyncio event loop running on the main thread for the entire session, which is incompatible with pytest-asyncio tests."

Several tests (e.g., test_client_storage.py::test_client_side_state, test_upload.py::test_upload_file_multiple, test_connection_banner.py::test_connection_banner) are both async and request Playwright's page fixture. With asyncio_mode = "auto" and asyncio_default_fixture_loop_scope = "function", pytest-asyncio creates a per-test event loop; if pytest-playwright's sync wrapper also drives an event loop on the main thread during the same session, you may still hit the original conflict. Please confirm these tests pass end-to-end in CI before merging.

Comment on lines 370 to +372
suffix = "_secondary" if secondary else ""

upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[
1 if secondary else 0
]
assert upload_box
upload_button = driver.find_element(By.ID, f"upload_button{suffix}")
assert upload_button
upload_box = page.locator("input[type='file']").nth(1 if secondary else 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inconsistent path normalization for single-file assertion

The single-file case no longer normalizes the filename before asserting, while the multi-file tests still apply Path(name).name. The old assertion was Path(selected_files.text).name == Path(exp_name).name, which strips any OS path prefix. For browsers the file.name property is path-free so this is unlikely to fail in practice, but the inconsistency between the two cases is fragile.

Suggested change
suffix = "_secondary" if secondary else ""
upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[
1 if secondary else 0
]
assert upload_box
upload_button = driver.find_element(By.ID, f"upload_button{suffix}")
assert upload_button
upload_box = page.locator("input[type='file']").nth(1 if secondary else 0)
selected_files = page.locator(f"#selected_files{suffix}")
assert Path(selected_files.text_content() or "").name == Path(exp_name).name

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 24, 2026

Merging this PR will degrade performance by 6.23%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 8 untouched benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_compile_stateful[_complicated_page] 595.6 µs 635.2 µs -6.23%

Comparing claude/selenium-to-playwright-migration-7qluV (16ff9dd) with main (1a64caa)

Open in CodSpeed

claude added 10 commits April 24, 2026 00:42
Unit tests import `attrs` but it was not declared in the dev dependency
group.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
pytest-asyncio's event-loop runner and pytest-playwright's sync driver
can't share the main thread — mixing `@pytest.mark.asyncio` with the
sync `page` fixture trips teardown with "Cannot run the event loop
while another loop is running" (warned now, error in future
pytest-asyncio releases).

All tests that touch the Playwright `page` fixture are now plain `def`
tests: `asyncio.sleep` becomes `time.sleep`, and the one test that
needs an async Redis client (`test_connection_banner`) wraps its
`await redis.get(...)` calls in a small `asyncio.run` helper and
switches its fixture to a sync `@pytest.fixture` that also runs
`redis.aclose()` via `asyncio.run`.  The app factory functions
(which legitimately define `async def` event handlers) are untouched.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
Drop the `asyncio.run` wrappers around `redis.get`/`redis.aclose` and
switch the `redis` fixture to `get_redis_sync()` with
`redis.Redis.close()` teardown.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
pytest-playwright's sync driver owns the main-thread event loop for the
whole session; pytest-asyncio's scoped runner tries to initialise its
own and races with it at teardown even when no `async def test_` is
collected.  None of the integration tests need pytest-asyncio after
the Playwright migration, so disable the plugin for this invocation.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
pre-commit uses ruff 0.15.8, which treats `async def` with only a
`yield` (i.e. an async generator used by @asynccontextmanager) as not
using async features; newer ruff 0.15.11 got smarter and no longer
flags them.  Add `# noqa: RUF029` on the five affected functions so
the hook passes on whichever ruff version CI picks up.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
- `-p no:asyncio` alone may still trip on `asyncio_mode = "auto"` in
  pyproject.toml; add `-o asyncio_mode=strict` to override it too.
- `playwright install chromium --only-shell` doesn't fetch the OS
  libraries chromium needs; add `--with-deps` so a fresh ubuntu-22.04
  runner has them.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
- main's ruff does not flag RUF029 on @asynccontextmanager+yield
  functions, so adding `# noqa: RUF029` just creates RUF100 "unused
  noqa" errors on CI's ruff.  Match main exactly for the 5 affected
  functions.
- Drop `--with-deps` from the playwright install step (main's split-
  Playwright job never needed it).  Drop the redundant
  `-o asyncio_mode=strict`; `-p no:asyncio` alone is sufficient.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
- `page.context.cookies()` returns Playwright's TypedDict ``Cookie``
  where `name` is not required; switch to ``cookie_info.get("name", "")``.
- In `test_linked_state.tab_factory`, the inner ``factory`` closure
  lost the ``linked_state.frontend_url is not None`` narrowing; capture
  the URL in a local variable before defining the closure so pyright
  keeps the non-None type.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
Print pytest/playwright versions and the Playwright browser cache
contents before running tests so we can see what's actually failing
when the 16-job matrix all exits with code 1 and no log is readable
through the unauthenticated Actions UI.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
`playwright.__version__` isn't an attribute on the module, so the
previous diagnostics step bombed before the test step ran.  Drop that,
use `uv pip list` / `uv run pytest --version` / `uv run playwright
--version` instead, and mark the step continue-on-error so it can't
swallow the actual pytest run.

https://claude.ai/code/session_01B2zzr5B8FE4R5ePeQaetaZ
@BABTUNA
Copy link
Copy Markdown

BABTUNA commented Apr 25, 2026

Opened follow-up reliability issue #6384 and a small follow-up PR #6385 against this branch.\n\nIt switches ests/integration/utils.py::poll_for_navigation from AppHarness.expect polling to Playwright-native page.wait_for_url(...) and adds a regression unit test.

@masenf
Copy link
Copy Markdown
Collaborator Author

masenf commented Apr 25, 2026

Thanks for the review. In the future, you don't need a separate issue tracking a change unless the code has merged to main. Just a comment on the PR/follow on PR targeting the branch is fine. 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants