Skip to content

feat: Add PlaywrightBrowser - #195

Draft
kbond wants to merge 1 commit into
zenstruck:1.xfrom
kbond:playwright
Draft

feat: Add PlaywrightBrowser#195
kbond wants to merge 1 commit into
zenstruck:1.xfrom
kbond:playwright

Conversation

@kbond

@kbond kbond commented Aug 8, 2026

Copy link
Copy Markdown
Member

Adds a PlaywrightBrowser: it drives a real browser (Chromium, Firefox or WebKit) but intercepts its requests and passes them to your booted kernel, so no webserver is required. The browser talks to the same application instance and container as the test, so mocked services and an in-memory database behave as they do with the KernelBrowser.

Also deprecates PantherBrowser in favour of it (@deprecated since 1.11), and moves the response status/header assertions, container access and the security helpers up to Zenstruck\Browser so both browsers share them.

Out of scope by design: JSON/XML assertions, HTTP-method actions and HttpOptions. Playwright is for browser testing, not API testing.

Required

Nice to have

  • Profiler support (withProfiling()/profile()), with the same one-shot semantics as the KernelBrowser.
  • Add getContainer() to the kernel client playwright-php/playwright-symfony#36 (getContainer()) - drops the test-container lookup currently duplicated in HasBrowser.
  • Reuse one browser across tests, taking a fresh context each time for isolation, instead of launching a browser per test - enabled by Reuse one browser across contexts instead of relaunching playwright-php/playwright-symfony#31. Extra browsers are still launched when a single test asks for more than one. Takes PlaywrightBrowserTest from 30s to 12s with concurrent browsers capped at 2.
  • Support PHP 8.2 and Symfony 6.4 playwright-php/playwright-symfony#39 - PHP 8.2 and Symfony 6.4 support. It required PHP >=8.3 and Symfony ^7.0, which would have limited the Playwright tests to a fraction of the CI matrix; they now run in every job.
  • Share actingAs(), assertAuthenticated() and assertNotAuthenticated() with the KernelBrowser - unblocked by Add loginUser and extend WebTestCase playwright-php/playwright-symfony#28, which adds loginUser() to the client with a matching signature. They moved to Zenstruck\Browser rather than being duplicated, and KernelBrowserAuthenticationTest is gone: its coverage is now shared, including a real form login that survives losing the session cookie via remember-me (previously markTestIncomplete since 2022).
  • Share throwExceptions(), catchExceptions() and expectException() with the KernelBrowser. They moved to Zenstruck\Browser, the expectation machinery moved to the shared Session\Driver base, and they are wired to the catch_exceptions option and BROWSER_CATCH_EXCEPTIONS. Five tests moved into BrowserTests so both browsers run them. Two things a real browser does differently: a request it has not finished is handled after the current call returns, so wrapRequest() restores the previous catching setting rather than leaving it off (which also settles a todo the KernelBrowser carried), and a link click that throws leaves the browser where it was, since no response reaches it, so the shared test does not assert the url afterwards. fails_if_trying_to_manipulate_exception_page still sits in KernelBrowserTests: Session::ensureNoException() reads the rendered page rather than the raw response, so moving it needs a driver-level raw-content accessor.
  • Cookies. The real browser holds them, so mutating the client's jar had no effect. ->use(CookieJar $jar) now hands PlaywrightBrowser a jar that proxies to the browser, in the same spirit as Symfony\Component\Panther\Cookie\CookieJar.

Future scope

Redirect interception

interceptRedirects(), followRedirect(), assertRedirected(), assertRedirectedTo() and clickAndIntercept() look achievable. PlaywrightKernelClient takes a $hookReceiver whose afterResponse() is handed the response object that is later fulfilled to the browser, so rewriting a 3xx into a 200 parks the browser on the redirect URL with the status and Location recorded. The body has to be neutralized too: RedirectResponse embeds a <meta http-equiv="refresh"> that navigates regardless of the status. Stepping /redirect1/redirect2/redirect3/page1 one hop at a time works with that in place.

It belongs upstream rather than here though: a followRedirects(bool) on the client (the AbstractBrowser spelling) can fulfill a mutated clone and leave getLastSymfonyResponse() truthful, which a hook-only version downstream cannot - it mutates the same object getStatusCode() reads. Telling document navigations from subresources wants playwright-php/playwright#124; the Accept header discriminates well enough in the meantime.

Auto-wait behaviour

Playwright auto-waits on actions but not on queries: click() retries until the element is actionable, while assertVisible() is a one-shot check. That is why the waitUntil*() methods exist alongside the assertions - making assertions retry would remove the split. The default timeout is also 30s, which feels long for an assertion and should be configurable. playwright-php/playwright-symfony#35 does the same thing for PlaywrightTestCase's own assertion trait, which we don't use, but it is the same idea.

Uncaught javascript errors

Playwright reports uncaught javascript errors as a pageerror event, separate from console messages, and playwright-php does not expose it yet. They are therefore missing from saveConsoleLog(), dumpConsoleLog() and saveCurrentState() - precisely the situations where you'd want them. can_dump_console_log_with_throw_error is skipped until the event is surfaced upstream.

@kbond
kbond force-pushed the playwright branch 11 times, most recently from 86ea2f9 to d6e1bed Compare August 13, 2026 00:43
@kbond

kbond commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Now that actingAs() works with the PlaywrightBrowser (via playwright-php/playwright-symfony#28), it's worth knowing what it saves. Browser round-trips are where Playwright spends its time, and a form login costs four of them.

Measured with 100 authenticated tests of each style in a single process, three runs:

mean per test 100 tests
real form login ~178 ms ~17.8s
actingAs() ~52 ms ~5.2s

~71% less time, ~12.6s saved per 100 authenticated tests (~63s per 500). actingAs() is very consistent — median 52ms, min 50ms — because it's a fixed cost: a fresh browser context plus one navigation.

For contrast, the same comparison with the KernelBrowser saves under 2ms per test. This is a PlaywrightBrowser win specifically.

@kbond
kbond force-pushed the playwright branch 3 times, most recently from 3c7831f to c1a24b5 Compare August 13, 2026 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant