Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Before 1.0, breaking changes are released in minor versions.

### Changed

- `getCookieJar()` now returns a jar backed by the browser context, so it reads the cookies the browser actually holds and writing to it reaches the browser. The internal `CookieJarSync`, which copied cookies into a separate jar at a few fixed points, is gone.
- **BC break:** cookies in a `CookieJar` passed to `PlaywrightClient` are now written into the browser context. They were previously overwritten by the context's own cookies and never sent.
- **BC break:** `PlaywrightTestCase` now extends `WebTestCase` instead of `KernelTestCase`. Subclasses that define members inherited from `WebTestCase`, such as `createClient()`, must use compatible signatures.
- **BC break:** `PlaywrightTestCase::logout()` now accepts an optional firewall context and returns `static` instead of `void`. Overrides must change their signature to `logout(string $firewallContext = 'main'): static`.
- **BC break:** `assertSelectorExists()`, `assertSelectorNotExists()`, `assertSelectorTextContains()`, and `assertResponseIsSuccessful()` now use the public static Symfony `WebTestCase` signatures. Overrides of the previous protected instance methods must be updated. Calls from tests remain compatible.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ requests to a web server, Playwright intercepts all outgoing requests and routes
3. **Bridge Utilities**:
- `RequestConverter`: Translates Playwright requests (headers, body, cookies) to Symfony `HttpFoundation` requests.
- `ResponseConverter`: Translates Symfony responses back to Playwright `fulfill` options.
- `CookieJarSync`: Keeps the browser's cookies in sync with Symfony's `CookieJar`.
- `Cookie\CookieJar`: A BrowserKit `CookieJar` backed by the browser context, so the client's jar reads and
writes the cookies the browser actually holds.
4. **Browser Registry (`BrowserRegistry`)**: Browser lifecycle manager that handles process management and
`BrowserContext` isolation.
5. **Asset Layer (`AssetServer`)**: A high-performance bypassing layer that serves static files and AssetMapper assets
Expand Down
2 changes: 1 addition & 1 deletion docs/bridge/browserkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ $crawler = $client->submit($form);
- Real browser interactions: click/submit use Playwright, not synthetic HTTP.
- `request()` with method !== GET constructs and submits a synthetic in-page form to preserve browser semantics.
- Response mapping: uses last Playwright Response (status, headers) + page content for BrowserKit Response.
- Cookies: kept in sync between BrowserKit CookieJar and Playwright BrowserContext.
- Cookies: the BrowserKit CookieJar is backed by the Playwright BrowserContext, so both read and write one store.

## Options

Expand Down
18 changes: 18 additions & 0 deletions docs/cookies.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,24 @@ $context->deleteCookie(string $name);
$context->clearCookies();
```

### BrowserKit Cookie Jar

`getCookieJar()` returns a jar backed by the browser context, so it reflects what the browser holds, including cookies
set by javascript, and writing to it reaches the browser:

```php
use Symfony\Component\BrowserKit\Cookie;

$jar = $client->getCookieJar();

$jar->set(new Cookie('notice', '1', domain: 'localhost'));
$jar->get('notice');
$jar->expire('notice'); // removes every domain and path variant of the name
$jar->clear();
```

A cookie set without a domain is scoped to the host being browsed.

## Best Practices

1. ✅ **Always use domain parameter** instead of url
Expand Down
6 changes: 3 additions & 3 deletions docs/dom-crawler.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ Unlike standard `WebTestCase`, calling `click()` or `submit()` through the bridg
- Executes CSS transitions and animations.
- Respects `target="_blank"` and other browser-native behaviors.

### Automatic State Synchronization
### Cookies Come From the Browser

The bridge includes a synchronization layer (`CookieJarSync`) that ensures cookies set by the browser (via JS) and
cookies set by Symfony (via Headers) are always consistent within your test session.
`getCookieJar()` returns a BrowserKit jar backed by the browser context. Cookies set by the browser (via JS) and
cookies set by Symfony (via headers) live in one store, so there is nothing to keep consistent.

### Asynchronous Resilience

Expand Down
19 changes: 10 additions & 9 deletions src/BrowserKit/PlaywrightClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
use Playwright\Browser\BrowserContextInterface;
use Playwright\Exception\TimeoutException;
use Playwright\Page\PageInterface;
use Playwright\Symfony\Util\CookieJarSync;
use Playwright\Symfony\Cookie\CookieJar as PlaywrightCookieJar;
use Playwright\Symfony\Util\FormInteractor;
use Playwright\Symfony\Util\XPathHelper;
use Symfony\Component\BrowserKit\AbstractBrowser;
Expand Down Expand Up @@ -111,12 +111,18 @@ public function __construct(
?History $history = null,
?CookieJar $cookieJar = null,
) {
parent::__construct($server, $history, $cookieJar);
$jar = new PlaywrightCookieJar($context, static fn (): string => parse_url($page->url(), \PHP_URL_HOST) ?: 'localhost');

// a jar handed in was previously seeded from the context and then ignored by the browser;
// pushing its cookies in makes them count for real
foreach ($cookieJar?->all() ?? [] as $cookie) {
$jar->set($cookie);
}

parent::__construct($server, $history, $jar);

$this->context = $context;
$this->page = $page;

CookieJarSync::fromContext($this->cookieJar, $this->context);
}

/**
Expand Down Expand Up @@ -221,8 +227,6 @@ private function navigate(string $url): BrowserKitResponse
$status = $playwrightResponse?->status() ?? 200;
$headers = $playwrightResponse?->headers() ?? [];

CookieJarSync::toJarFromUrl($this->cookieJar, $this->context, $this->page->url());

return $this->createBrowserKitResponse($content, $status, $headers);
}

Expand Down Expand Up @@ -305,8 +309,6 @@ private function submitSyntheticForm(string $action, string $method, array $para
$status = 200;
$headers = [];

CookieJarSync::toJarFromUrl($this->cookieJar, $this->context, $this->page->url());

return $this->createBrowserKitResponse($content, $status, $headers);
}

Expand All @@ -316,7 +318,6 @@ private function refreshSnapshotAndResponse(): Crawler
$status = 200;
$headers = [];

CookieJarSync::toJarFromUrl($this->cookieJar, $this->context, $this->page->url());
$this->lastResponse = $this->createBrowserKitResponse($content, $status, $headers);

return new Crawler($content, $this->page->url());
Expand Down
16 changes: 5 additions & 11 deletions src/Client/PlaywrightKernelClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
use Playwright\Network\RequestInterface;
use Playwright\Page\PageInterface;
use Playwright\Symfony\Client\Interception\AssetServer;
use Playwright\Symfony\Util\CookieJarSync;
use Playwright\Symfony\Cookie\CookieJar;
use Playwright\Symfony\Util\FormInteractor;
use Playwright\Symfony\Util\XPathHelper;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -187,7 +187,9 @@ public function __construct(
private ?LoggerInterface $logger = null,
private readonly bool $debugLogging = false,
) {
parent::__construct($server);
$context = $browser->getContext();

parent::__construct($server, cookieJar: $context ? new CookieJar($context, static fn (): string => parse_url($baseUrl, \PHP_URL_HOST) ?: 'localhost') : null);

$this->session = $browser;

Expand All @@ -199,10 +201,7 @@ public function __construct(
$this->assetServer = $assetServer;
$this->logger = $logger ?? new NullLogger();

if ($context = $this->session->getContext()) {
$context->addInitScript(self::FETCH_REDIRECT_SCRIPT);
CookieJarSync::fromContext($this->getCookieJar(), $context);
}
$context?->addInitScript(self::FETCH_REDIRECT_SCRIPT);
}

public function catchExceptions(bool $catchExceptions): void
Expand All @@ -223,10 +222,6 @@ public function visit(string $path): PageInterface

$page->goto($url);

if ($context = $this->session->getContext()) {
CookieJarSync::toJarFromUrl($this->getCookieJar(), $context, $page->url());
}

return $page;
}

Expand Down Expand Up @@ -351,7 +346,6 @@ public function setCookie(string $name, string $value, array $options = []): voi
}

$context->addCookies([$cookie]);
CookieJarSync::toJarFromUrl($this->getCookieJar(), $context, $this->getBaseUrl());
}

public function getCookie(string $name, ?string $url = null): ?string
Expand Down
184 changes: 184 additions & 0 deletions src/Cookie/CookieJar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

declare(strict_types=1);

/*
* This file is part of the community-maintained Playwright PHP project.
* It is not affiliated with or endorsed by Microsoft.
*
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Playwright\Symfony\Cookie;

use Playwright\Browser\BrowserContextInterface;
use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\BrowserKit\CookieJar as BrowserKitCookieJar;

/**
* A BrowserKit cookie jar backed by the browser context.
*
* The real browser owns the cookies, so a jar holding its own copy is stale the moment javascript
* or a response changes one, and writing to it has no effect on what the browser sends. Every read
* and write here goes to the context instead, in the same spirit as
* {@see \Symfony\Component\Panther\Cookie\CookieJar}.
*
* updateFromSetCookie(), updateFromResponse() and flushExpiredCookies() are inherited: the first
* two funnel into set(), and the browser expires its own cookies.
*
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class CookieJar extends BrowserKitCookieJar
{
/**
* @param \Closure(): string $defaultDomain the domain to scope cookies set without one, usually
* the host currently being browsed
*/
public function __construct(
private readonly BrowserContextInterface $context,
private readonly \Closure $defaultDomain,
) {
}

public function set(Cookie $cookie): void
{
// playwright needs a domain/path pair: passing a url instead makes it derive the path from
// that url, which silently widens a cookie scoped to something narrower
$data = [
'name' => $cookie->getName(),
'value' => $cookie->getValue(),
'domain' => $cookie->getDomain() ?: ($this->defaultDomain)(),
'path' => $cookie->getPath(),
'secure' => $cookie->isSecure(),
'httpOnly' => $cookie->isHttpOnly(),
];

if (null !== $expires = $cookie->getExpiresTime()) {
$data['expires'] = (int) $expires;
}

// playwright only accepts these three, spelled exactly like this
$sameSite = match (strtolower((string) $cookie->getSameSite())) {
'strict' => 'Strict',
'lax' => 'Lax',
'none' => 'None',
default => null,
};

if (null !== $sameSite) {
$data['sameSite'] = $sameSite;
}

$this->context->addCookies([$data]);
}

public function get(string $name, string $path = '/', ?string $domain = null): ?Cookie
{
foreach ($this->all() as $cookie) {
if ($name !== $cookie->getName() || !str_starts_with($path, $cookie->getPath())) {
continue;
}

if (null === $domain || '' === $cookie->getDomain() || str_ends_with('.'.$domain, '.'.ltrim($cookie->getDomain(), '.'))) {
return $cookie;
}
}

return null;
}

/**
* Unlike BrowserKit's jar, which is keyed by domain and path, this removes the cookie from
* every domain and path it is stored under.
*/
public function expire(string $name, ?string $path = '/', ?string $domain = null): void
{
$this->context->deleteCookie($name);
}

public function clear(): void
{
$this->context->clearCookies();
}

/**
* @return Cookie[]
*/
public function all(): array
{
return array_map(self::toBrowserKitCookie(...), $this->context->cookies());
}

/**
* @return array<string, string>
*/
public function allValues(string $uri, bool $returnsRawValue = false): array
{
$values = [];

// the browser does the matching the parent does by hand: domain, path, secure and expiry
foreach ($this->context->cookies([$uri]) as $cookie) {
$cookie = self::toBrowserKitCookie($cookie);

$values[$cookie->getName()] = $returnsRawValue ? $cookie->getRawValue() : $cookie->getValue();
}

return $values;
}

/**
* @return array<string, string>
*/
public function allRawValues(string $uri): array
{
return $this->allValues($uri, true);
}

/**
* @param array<string, mixed> $cookie
*/
private static function toBrowserKitCookie(array $cookie): Cookie
{
$sameSite = self::toString($cookie['sameSite'] ?? null);

return new Cookie(
name: self::toString($cookie['name'] ?? null),
value: self::toString($cookie['value'] ?? null),
expires: self::normalizeExpires($cookie['expires'] ?? null),
path: self::toString($cookie['path'] ?? null) ?: '/',
domain: self::toString($cookie['domain'] ?? null),
secure: (bool) ($cookie['secure'] ?? false),
httponly: (bool) ($cookie['httpOnly'] ?? false),
// playwright capitalizes it, BrowserKit round-trips whatever it was given
samesite: '' === $sameSite ? null : strtolower($sameSite),
);
}

/**
* Playwright reports "expires" as a number: -1 for session cookies, a Unix timestamp
* (possibly float) otherwise. BrowserKit only accepts an int since 8.1; before that the
* parameter is ?string, parsed with createFromFormat('U'), so a numeric string is what
* satisfies every supported version. Past timestamps count as expired, so negatives must
* map to null.
*/
private static function normalizeExpires(mixed $expires): ?string
{
if (!is_numeric($expires)) {
return null;
}

$timestamp = (int) $expires;

return $timestamp < 0 ? null : (string) $timestamp;
}

private static function toString(mixed $value): string
{
return is_scalar($value) ? (string) $value : '';
}
}
Loading
Loading