diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f74e1..4f3a208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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/" 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 401053e..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, 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 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..5816320 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', '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; @@ -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..1fcdc32 100644 --- a/tests/Client/PlaywrightKernelClientTest.php +++ b/tests/Client/PlaywrightKernelClientTest.php @@ -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, @@ -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); } @@ -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); } diff --git a/tests/Functional/RouteOrderTest.php b/tests/Functional/RouteOrderTest.php new file mode 100644 index 0000000..81b9ec4 --- /dev/null +++ b/tests/Functional/RouteOrderTest.php @@ -0,0 +1,96 @@ +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 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' => '

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)); + } +}