Skip to content
Draft
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ Before 1.0, breaking changes are released in minor versions.

## [Unreleased]

### Changed

- **BC break:** the kernel route is registered when the client is created, so every route a test adds on the page now runs before it, including routes added before the first visit. A test route must call `$route->fallback()`, not `$route->continue()`, to hand a request to the kernel: `continue()` sends it to the network. This requires the Playwright PHP release that chains route handlers.
- Requests to hosts the kernel does not intercept are handed on with `fallback()` instead of `continue()`, and the debug log message for them is now `Falling back for external request` instead of `Continuing external request`.

### Fixed

- A route a test added on the page before the first visit never saw requests the kernel serves, and requests to hosts the kernel does not intercept went to the network without reaching context routes.

## [0.11.0] - 2026-09-20

### Changed
Expand Down
8 changes: 7 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
],
"require": {
"php": ">=8.2",
"playwright-php/playwright": "^1.5",
"playwright-php/playwright": "dev-fix/route-handler-chain as 1.5.99",
"symfony/browser-kit": "^6.4 || ^7.0 || ^8.0",
"symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0",
"symfony/http-foundation": "^6.4 || ^7.0 || ^8.0",
Expand All @@ -49,6 +49,12 @@
"symfony/ux-turbo": "^2.33 || ^3.0",
"symfony/web-profiler-bundle": "^6.4 || ^7.0 || ^8.0"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/Mir-Zairan/playwright"
}
],
"autoload": {
"psr-4": {
"Playwright\\Symfony\\": "src/"
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ When you call `$this->visit('/dashboard')`:
5. **Fulfillment**: The browser receives the response and renders the DOM.
6. **DomCrawler Sync**: The client builds a fresh `Crawler` from the browser's live `page->content()`.

The kernel route is the oldest route on the page, registered when the client is created. Playwright runs the newest
route first, so a route a test adds on the page runs before it and hands a request to it with `$route->fallback()`;
context routes only see what the page routes fall back on, which the kernel route does for hosts it does not intercept.

A client is bound to the page it was created with: when `BrowserRegistry::restartContext()` or `resetSessions()` closes
that page, create a new client.

## Service Container Integration

The bundle optimizes the DI container for testing:
Expand Down
2 changes: 1 addition & 1 deletion docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The environment variable always wins over configuration.
- Logs are sent to the `monolog.logger.playwright` channel when available, otherwise the default `logger` service.
- When debug logging is enabled:
- `info`: emitted after each intercepted request (includes method, URI, status, duration).
- `debug`: emitted for routing decisions (asset hits/misses, continued external requests) and navigation calls.
- `debug`: emitted for routing decisions (asset hits/misses, external requests handed on with `fallback()`) and navigation calls.
- Errors (e.g. kernel exceptions) are logged regardless of the debug flag.

### Suggested Monolog config
Expand Down
2 changes: 1 addition & 1 deletion docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function testWithMockedExternalApi(): void
return;
}

$route->continue();
$route->fallback();
});

$this->visit('/dashboard'); // Dashboard calls the external API
Expand Down
22 changes: 7 additions & 15 deletions src/Client/PlaywrightKernelClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ class PlaywrightKernelClient extends AbstractBrowser
/** @var string[] */
private array $interceptedHosts = ['localhost', '127.0.0.1', 'testapp.local'];
private ?object $hookReceiver = null;
private bool $interceptorSetUp = false;
private ?AssetServer $assetServer;
private ?string $lastProfileToken = null;
private bool $continuingRedirect = false;
Expand Down Expand Up @@ -201,6 +200,9 @@ public function __construct(
$this->assetServer = $assetServer;
$this->logger = $logger ?? new NullLogger();

// registered before any route a test adds, so those run first and can fall back to the kernel
$this->setupRequestInterception();

if ($context = $this->session->getContext()) {
$context->addInitScript(self::FETCH_REDIRECT_SCRIPT);
CookieJarSync::fromContext($this->getCookieJar(), $context);
Expand All @@ -220,7 +222,6 @@ public function catchExceptions(bool $catchExceptions): void
*/
public function visit(string $path): PageInterface
{
$this->ensureInterceptorSetUp();
$url = $this->getBaseUrl().$path;
$this->log('debug', 'Navigating with Playwright', ['url' => $url]);
$page = $this->session->getPage();
Expand Down Expand Up @@ -282,7 +283,6 @@ public function context(): ?BrowserContextInterface
*/
public function click(Link $link, array $serverParameters = []): Crawler
{
$this->ensureInterceptorSetUp();
$xpath = XPathHelper::buildXPath($link->getNode());
$page = $this->getPage();
if (null === $page) {
Expand All @@ -302,7 +302,6 @@ public function click(Link $link, array $serverParameters = []): Crawler
*/
public function submit(Form $form, array $values = [], array $serverParameters = []): Crawler
{
$this->ensureInterceptorSetUp();
if (!empty($values)) {
$form->setValues($values);
}
Expand Down Expand Up @@ -724,12 +723,13 @@ private function setupRequestInterception(): void
$url = parse_url($request->url());

if (!$this->shouldInterceptRequest($url)) {
$this->log('debug', 'Continuing external request', [
$this->log('debug', 'Falling back for external request', [
'url' => $request->url(),
'method' => $request->method(),
]);
if (method_exists($route, 'continue')) {
$route->continue();
// not continue(): that would skip context routes on its way to the network
if (method_exists($route, 'fallback')) {
$route->fallback();
}

return;
Expand Down Expand Up @@ -994,14 +994,6 @@ private function profiler(): ?Profiler
return $profiler instanceof Profiler ? $profiler : null;
}

private function ensureInterceptorSetUp(): void
{
if (!$this->interceptorSetUp) {
$this->setupRequestInterception();
$this->interceptorSetUp = true;
}
}

/**
* @param array<string, mixed> $context
*/
Expand Down
6 changes: 6 additions & 0 deletions tests/Client/Fixtures/FakeRoute.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
class FakeRoute
{
public bool $continued = false;
public bool $fellBack = false;
public bool $fulfilled = false;
public ?array $fulfilledOptions = null;
public ?string $redirectUrl = null;
Expand All @@ -38,6 +39,11 @@ public function continue(): void
$this->continued = true;
}

public function fallback(): void
{
$this->fellBack = true;
}

public function fulfill(array $options): void
{
$this->fulfilled = true;
Expand Down
32 changes: 27 additions & 5 deletions tests/Client/PlaywrightKernelClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,27 @@ public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST,
self::assertStringContainsString('redirects <= 10', $this->context->initScripts[0]);
}

public function testNonInterceptedRequestContinues(): void
public function testKernelRouteIsRegisteredWhenTheClientIsCreated(): void
{
new PlaywrightKernelClient(
$this->browser,
new class implements HttpKernelInterface {
public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST, bool $catch = true): SymfonyResponse
{
return new SymfonyResponse('from the kernel');
}
},
new RequestConverter(),
new ResponseConverter(),
);

$route = $this->page->triggerRequest(new MockRequest(url: 'http://localhost/hello'));

self::assertTrue($route->fulfilled);
self::assertSame('from the kernel', $route->fulfilledOptions['body'] ?? null);
}

public function testNonInterceptedRequestFallsBack(): void
{
$client = new PlaywrightKernelClient(
$this->browser,
Expand All @@ -340,11 +360,12 @@ public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST,

$client->visit('/anything');

// Simulate external host -> should continue
// Simulate external host -> should fall back
$mock = new MockRequest(url: 'http://example.com/page', method: 'GET');
$route = $this->page->triggerRequest($mock);

self::assertTrue($route->continued);
self::assertTrue($route->fellBack);
self::assertFalse($route->continued);
self::assertFalse($route->fulfilled);
}

Expand Down Expand Up @@ -1191,11 +1212,12 @@ public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST,

$client->visit('/test');

// Malformed URL request should continue (not intercept)
// Malformed URL request should fall back (not intercept)
$mock = new MockRequest(url: 'not-a-valid-url', method: 'GET');
$route = $this->page->triggerRequest($mock);

self::assertTrue($route->continued);
self::assertTrue($route->fellBack);
self::assertFalse($route->continued);
self::assertFalse($route->fulfilled);
}

Expand Down
96 changes: 96 additions & 0 deletions tests/Functional/RouteOrderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?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\Tests\Functional;

use Playwright\Network\RouteInterface;
use Playwright\Symfony\Test\Assert\PlaywrightTestAssertionsTrait;
use Playwright\Symfony\Test\PlaywrightTestCase;
use Playwright\Symfony\Tests\Fixtures\App\TestKernel;
use Symfony\Component\HttpKernel\KernelInterface;

final class RouteOrderTest extends PlaywrightTestCase
{
use PlaywrightTestAssertionsTrait;

protected static function createKernel(array $options = []): KernelInterface
{
return new TestKernel('test', true);
}

public function testRouteAddedBeforeTheFirstVisitCanFallBackToTheKernel(): void
{
$seen = [];
$this->getPage()->route('**/*', static function (RouteInterface $route) use (&$seen): void {
$seen[] = $route->request()->url();
$route->fallback();
});

$this->visit('/hello');

$this->assertPageContains('hello from app');
self::assertContains('http://localhost/hello', $seen);
}

public function testRouteAddedBeforeTheFirstVisitWinsOverTheKernel(): void
{
$this->getPage()->route('**/hello', static function (RouteInterface $route): void {
$route->fulfill(['status' => 200, 'contentType' => 'text/html', 'body' => '<p>from the test route</p>']);
});

$this->visit('/hello');

$this->assertPageContains('from the test route');
self::assertNull($this->getLastResponse());
}

public function testRouteAddedAfterTheFirstVisitCanFallBackToTheKernel(): void
{
$this->visit('/hello');
$seen = [];
$this->getPage()->route('**/*', static function (RouteInterface $route) use (&$seen): void {
$seen[] = $route->request()->url();
$route->fallback();
});

$this->visit('/hello?again=1');

$this->assertPageContains('hello from app');
self::assertContains('http://localhost/hello?again=1', $seen);
self::assertSame('1', $this->getLastRequest()?->query->get('again'));
}

public function testContextRoutesOnlySeeRequestsTheKernelDoesNotServe(): void
{
$hosts = [];
$this->getPlaywrightClient()->context()?->route('**/*', static function (RouteInterface $route) use (&$hosts): void {
$host = parse_url($route->request()->url(), \PHP_URL_HOST);
$hosts[] = $host;
if ('outside.test' !== $host) {
$route->abort();

return;
}
$route->fulfill(['status' => 200, 'contentType' => 'text/html', 'body' => '<p>from the context route</p>']);
});

$this->visit('/hello');
$this->assertPageContains('hello from app');

$this->getPage()->goto('http://outside.test/');

$this->assertPageContains('from the context route');
self::assertSame(['outside.test'], array_unique($hosts));
}
}
Loading