From 08b8faeff537219da631324f6964d2763af80c95 Mon Sep 17 00:00:00 2001 From: Mir-Zairan Date: Thu, 1 Oct 2026 17:54:47 +0530 Subject: [PATCH 1/3] Register the kernel route before any test route The kernel route was added on the first visit, so a route a test added before it never ran, and requests to other hosts went to the network past context routes. It is now the oldest page route and passes those on with fallback(). --- CHANGELOG.md | 4 ++ docs/logging.md | 2 +- docs/recipes.md | 2 +- src/Client/PlaywrightKernelClient.php | 22 +++---- tests/Client/Fixtures/FakeRoute.php | 6 ++ tests/Client/PlaywrightKernelClientTest.php | 12 ++-- tests/Functional/RouteOrderTest.php | 69 +++++++++++++++++++++ 7 files changed, 95 insertions(+), 22 deletions(-) create mode 100644 tests/Functional/RouteOrderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f74e1..29d1e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Before 1.0, breaking changes are released in minor versions. ## [Unreleased] +### Fixed + +- Register the kernel route as soon as the client is created, so routes a test adds on the page run before it and can `fallback()` to it, and pass requests to other hosts on with `fallback()` so context routes still see them. + ## [0.11.0] - 2026-09-20 ### Changed diff --git a/docs/logging.md b/docs/logging.md index 401053e..2aa0f31 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -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 passed on) and navigation calls. - Errors (e.g. kernel exceptions) are logged regardless of the debug flag. ### Suggested Monolog config diff --git a/docs/recipes.md b/docs/recipes.md index 8de8e1b..858196a 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -35,7 +35,7 @@ public function testWithMockedExternalApi(): void return; } - $route->continue(); + $route->fallback(); }); $this->visit('/dashboard'); // Dashboard calls the external API diff --git a/src/Client/PlaywrightKernelClient.php b/src/Client/PlaywrightKernelClient.php index 10cbafb..a343a2b 100644 --- a/src/Client/PlaywrightKernelClient.php +++ b/src/Client/PlaywrightKernelClient.php @@ -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; @@ -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); @@ -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(); @@ -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) { @@ -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); } @@ -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', 'Passing external request on', [ '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; @@ -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 $context */ diff --git a/tests/Client/Fixtures/FakeRoute.php b/tests/Client/Fixtures/FakeRoute.php index 12b351f..0f0b70f 100644 --- a/tests/Client/Fixtures/FakeRoute.php +++ b/tests/Client/Fixtures/FakeRoute.php @@ -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; @@ -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; diff --git a/tests/Client/PlaywrightKernelClientTest.php b/tests/Client/PlaywrightKernelClientTest.php index 3dc17da..d8781d1 100644 --- a/tests/Client/PlaywrightKernelClientTest.php +++ b/tests/Client/PlaywrightKernelClientTest.php @@ -322,7 +322,7 @@ public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST, self::assertStringContainsString('redirects <= 10', $this->context->initScripts[0]); } - public function testNonInterceptedRequestContinues(): void + public function testNonInterceptedRequestFallsBack(): void { $client = new PlaywrightKernelClient( $this->browser, @@ -340,11 +340,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); } @@ -1191,11 +1192,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); } diff --git a/tests/Functional/RouteOrderTest.php b/tests/Functional/RouteOrderTest.php new file mode 100644 index 0000000..e4b62b5 --- /dev/null +++ b/tests/Functional/RouteOrderTest.php @@ -0,0 +1,69 @@ +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' => '

from the test route

']); + }); + + $this->visit('/hello'); + + $this->assertPageContains('from the test route'); + self::assertNull($this->getLastResponse()); + } + + public function testRequestToAHostTheKernelDoesNotServeReachesContextRoutes(): void + { + $this->visit('/hello'); + $this->getPlaywrightClient()->context()?->route('http://outside.test/**', static function (RouteInterface $route): void { + $route->fulfill(['status' => 200, 'contentType' => 'text/html', 'body' => '

from the context route

']); + }); + + $this->getPage()->goto('http://outside.test/'); + + $this->assertPageContains('from the context route'); + } +} From 0d136e1538893eac4947a89bdacce55d60b28c5a Mon Sep 17 00:00:00 2001 From: Mir-Zairan Date: Thu, 1 Oct 2026 18:10:44 +0530 Subject: [PATCH 2/3] Describe the route order change and cover routes added after the first visit The changelog now says test routes run before the kernel route and must fall back to reach it, and the architecture docs explain the route order. The debug log for external requests names the call it makes. --- CHANGELOG.md | 7 ++++- docs/architecture.md | 7 +++++ docs/logging.md | 2 +- src/Client/PlaywrightKernelClient.php | 2 +- tests/Client/PlaywrightKernelClientTest.php | 20 +++++++++++++ tests/Functional/RouteOrderTest.php | 31 +++++++++++++++++++-- 6 files changed, 64 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d1e2e..4f3a208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ 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 -- Register the kernel route as soon as the client is created, so routes a test adds on the page run before it and can `fallback()` to it, and pass requests to other hosts on with `fallback()` so context routes still see them. +- 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 diff --git a/docs/architecture.md b/docs/architecture.md index 1c15c86..3a3bd61 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: diff --git a/docs/logging.md b/docs/logging.md index 2aa0f31..186f0c8 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -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, external requests passed on) 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 diff --git a/src/Client/PlaywrightKernelClient.php b/src/Client/PlaywrightKernelClient.php index a343a2b..5816320 100644 --- a/src/Client/PlaywrightKernelClient.php +++ b/src/Client/PlaywrightKernelClient.php @@ -723,7 +723,7 @@ private function setupRequestInterception(): void $url = parse_url($request->url()); if (!$this->shouldInterceptRequest($url)) { - $this->log('debug', 'Passing external request on', [ + $this->log('debug', 'Falling back for external request', [ 'url' => $request->url(), 'method' => $request->method(), ]); diff --git a/tests/Client/PlaywrightKernelClientTest.php b/tests/Client/PlaywrightKernelClientTest.php index d8781d1..1fcdc32 100644 --- a/tests/Client/PlaywrightKernelClientTest.php +++ b/tests/Client/PlaywrightKernelClientTest.php @@ -322,6 +322,26 @@ public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST, self::assertStringContainsString('redirects <= 10', $this->context->initScripts[0]); } + 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( diff --git a/tests/Functional/RouteOrderTest.php b/tests/Functional/RouteOrderTest.php index e4b62b5..81b9ec4 100644 --- a/tests/Functional/RouteOrderTest.php +++ b/tests/Functional/RouteOrderTest.php @@ -55,15 +55,42 @@ public function testRouteAddedBeforeTheFirstVisitWinsOverTheKernel(): void self::assertNull($this->getLastResponse()); } - public function testRequestToAHostTheKernelDoesNotServeReachesContextRoutes(): void + public function testRouteAddedAfterTheFirstVisitCanFallBackToTheKernel(): void { $this->visit('/hello'); - $this->getPlaywrightClient()->context()?->route('http://outside.test/**', static function (RouteInterface $route): void { + $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' => '

from the context route

']); }); + $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)); } } From b27171c4c1fc593c180ce360826bb5a32b018c30 Mon Sep 17 00:00:00 2001 From: Mir-Zairan Date: Thu, 1 Oct 2026 18:38:39 +0530 Subject: [PATCH 3/3] Test against the playwright-php route chain branch Temporary: CI needs playwright-php/playwright#182 to run the new route tests. Drop this commit and raise the constraint once that fix is released. --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8caf375..3a3e542 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -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/"