From cd84b170466bd55e9c4077c909b04eb79c6c9fb5 Mon Sep 17 00:00:00 2001 From: Jonathan Mateman Date: Sat, 18 Jul 2026 13:59:27 +0200 Subject: [PATCH 1/3] Add playwright routes support --- src/Api/Concerns/HasRoutes.php | 31 ++++++++++ src/Api/From.php | 2 + src/Api/On.php | 28 ++++++++- src/Api/PendingAwaitablePage.php | 46 +++++++++++--- src/Api/Webpage.php | 3 +- src/Playwright/Client.php | 40 ++++++++++++ src/Playwright/Context.php | 10 +++ src/Playwright/Playwright.php | 3 + src/Playwright/Request.php | 71 ++++++++++++++++++++++ src/Playwright/Route.php | 91 ++++++++++++++++++++++++++++ tests/Browser/Webpage/RoutesTest.php | 66 ++++++++++++++++++++ 11 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 src/Api/Concerns/HasRoutes.php create mode 100644 src/Playwright/Request.php create mode 100644 src/Playwright/Route.php create mode 100644 tests/Browser/Webpage/RoutesTest.php diff --git a/src/Api/Concerns/HasRoutes.php b/src/Api/Concerns/HasRoutes.php new file mode 100644 index 00000000..d1722d23 --- /dev/null +++ b/src/Api/Concerns/HasRoutes.php @@ -0,0 +1,31 @@ +page->context()->setNetworkInterceptionPatterns([ + 'patterns' => [ + [ + 'glob' => $pattern, + ], + ], + ]); + Route::registerRouteHandler($pattern, $handler); + } +} diff --git a/src/Api/From.php b/src/Api/From.php index 107c4498..b077b7ae 100644 --- a/src/Api/From.php +++ b/src/Api/From.php @@ -23,6 +23,7 @@ public function __construct( private Device $device, private string $url, private array $options, + private readonly array $routes, ) { // } @@ -141,6 +142,7 @@ private function city(City $city): PendingAwaitablePage $this->device, $this->url, $this->options, + $this->routes, )) ->geolocation($city->geolocation()['latitude'], $city->geolocation()['longitude']) ->withTimezone($city->timezone()) diff --git a/src/Api/On.php b/src/Api/On.php index baec658f..6455f468 100644 --- a/src/Api/On.php +++ b/src/Api/On.php @@ -22,6 +22,7 @@ public function __construct( private Device $device, private string $url, private array $options, + private readonly array $routes, ) { // } @@ -39,6 +40,7 @@ public function __call(string $name, array $arguments): mixed $this->device, $this->url, $this->options, + $this->routes, ))->{$name}(...$arguments); } @@ -52,6 +54,7 @@ public function desktop(): self Device::DESKTOP, $this->url, $this->options, + $this->routes, ); } @@ -65,6 +68,7 @@ public function mobile(): self Device::MOBILE, $this->url, $this->options, + $this->routes, ); } @@ -78,6 +82,7 @@ public function macbook16(): self Device::MACBOOK_16, $this->url, $this->options, + $this->routes, ); } @@ -91,6 +96,7 @@ public function macbook14(): self Device::MACBOOK_14, $this->url, $this->options, + $this->routes, ); } @@ -104,6 +110,7 @@ public function macbookAir(): self Device::MACBOOK_AIR, $this->url, $this->options, + $this->routes, ); } @@ -117,6 +124,7 @@ public function iPhone15Pro(): self Device::IPHONE_15_PRO, $this->url, $this->options, + $this->routes, ); } @@ -130,6 +138,7 @@ public function iPhone15(): self Device::IPHONE_15, $this->url, $this->options, + $this->routes, ); } @@ -143,6 +152,7 @@ public function iPhone14Pro(): self Device::IPHONE_14_PRO, $this->url, $this->options, + $this->routes, ); } @@ -156,6 +166,7 @@ public function iPhoneSE(): self Device::IPHONE_SE, $this->url, $this->options, + $this->routes, ); } @@ -169,6 +180,7 @@ public function iPadPro(): self Device::IPAD_PRO, $this->url, $this->options, + $this->routes, ); } @@ -182,6 +194,7 @@ public function iPadMini(): self Device::IPAD_MINI, $this->url, $this->options, + $this->routes, ); } @@ -195,6 +208,7 @@ public function pixel8(): self Device::PIXEL_8, $this->url, $this->options, + $this->routes, ); } @@ -208,6 +222,7 @@ public function pixel7(): self Device::PIXEL_7, $this->url, $this->options, + $this->routes, ); } @@ -221,6 +236,7 @@ public function pixel6a(): self Device::PIXEL_6A, $this->url, $this->options, + $this->routes, ); } @@ -234,6 +250,7 @@ public function galaxyS24Ultra(): self Device::GALAXY_S24_ULTRA, $this->url, $this->options, + $this->routes, ); } @@ -247,6 +264,7 @@ public function galaxyS23(): self Device::GALAXY_S23, $this->url, $this->options, + $this->routes, ); } @@ -260,6 +278,7 @@ public function galaxyS22(): self Device::GALAXY_S22, $this->url, $this->options, + $this->routes, ); } @@ -273,6 +292,7 @@ public function galaxyNote20(): self Device::GALAXY_NOTE_20, $this->url, $this->options, + $this->routes, ); } @@ -286,6 +306,7 @@ public function galaxyTabS8(): self Device::GALAXY_TAB_S8, $this->url, $this->options, + $this->routes, ); } @@ -299,6 +320,7 @@ public function surfacePro9(): self Device::SURFACE_PRO_9, $this->url, $this->options, + $this->routes, ); } @@ -312,6 +334,7 @@ public function surfaceLaptop5(): self Device::SURFACE_LAPTOP_5, $this->url, $this->options, + $this->routes, ); } @@ -325,6 +348,7 @@ public function oneplus11(): self Device::ONEPLUS_11, $this->url, $this->options, + $this->routes, ); } @@ -337,7 +361,8 @@ public function xiaomi13(): self $this->browserType, Device::XIAOMI_13, $this->url, - $this->options + $this->options, + $this->routes, ); } @@ -351,6 +376,7 @@ public function huaweiP50(): self Device::HUAWEI_P50, $this->url, $this->options, + $this->routes, ); } } diff --git a/src/Api/PendingAwaitablePage.php b/src/Api/PendingAwaitablePage.php index 83ad7b77..4f153208 100644 --- a/src/Api/PendingAwaitablePage.php +++ b/src/Api/PendingAwaitablePage.php @@ -31,6 +31,7 @@ public function __construct( private readonly Device $device, private readonly string $url, private readonly array $options, + private readonly array $routes = [], ) { // } @@ -48,6 +49,23 @@ public function __call(string $name, array $arguments): mixed return $this->waitablePage->{$name}(...$arguments); } + /** + * @param callable(Route): bool $handler + */ + public function withRoute(string $pattern, callable $handler): self + { + return new self( + $this->browserType, + $this->device, + $this->url, + $this->options, + [ + ...$this->routes, + $pattern => $handler, + ], + ); + } + /** * Sets the color scheme to dark mode. */ @@ -56,7 +74,7 @@ public function inDarkMode(): self return new self($this->browserType, $this->device, $this->url, [ 'colorScheme' => ColorScheme::DARK->value, ...$this->options, - ]); + ], $this->routes); } /** @@ -67,7 +85,7 @@ public function inLightMode(): self return new self($this->browserType, $this->device, $this->url, [ 'colorScheme' => ColorScheme::LIGHT->value, ...$this->options, - ]); + ], $this->routes); } /** @@ -80,6 +98,7 @@ public function from(): From $this->device, $this->url, $this->options, + $this->routes, ); } @@ -93,6 +112,7 @@ public function on(): On $this->device, $this->url, $this->options, + $this->routes, ); } @@ -104,7 +124,7 @@ public function withLocale(string $locale): self return new self($this->browserType, $this->device, $this->url, [ 'locale' => $locale, ...$this->options, - ]); + ], $this->routes); } /** @@ -115,7 +135,7 @@ public function withUserAgent(string $userAgent): self return new self($this->browserType, $this->device, $this->url, [ 'userAgent' => $userAgent, ...$this->options, - ]); + ], $this->routes); } /** @@ -126,7 +146,7 @@ public function withHost(string $host): self return new self($this->browserType, $this->device, $this->url, [ 'host' => $host, ...$this->options, - ]); + ], $this->routes); } /** @@ -137,7 +157,7 @@ public function withTimezone(string $timezone): self return new self($this->browserType, $this->device, $this->url, [ 'timezoneId' => $timezone, ...$this->options, - ]); + ], $this->routes); } /** @@ -151,7 +171,7 @@ public function geolocation(float $latitude, float $longitude): self 'geolocation' => $geolocation, 'permissions' => ['geolocation'], ...$this->options, - ]); + ], $this->routes); } /** @@ -184,10 +204,18 @@ private function buildAwaitablePage(array $options): AwaitableWebpage $url = ComputeUrl::from($this->url); - return new AwaitableWebpage( - $context->newPage()->goto($url, $options), + $awaitableWebPage = new AwaitableWebpage( + $context->newPage(), $url, ); + + foreach ($this->routes as $pattern => $handler) { + $awaitableWebPage->route($pattern, $handler); + } + + $awaitableWebPage->page()->goto($url, $options); + + return $awaitableWebPage; } /** diff --git a/src/Api/Webpage.php b/src/Api/Webpage.php index d42876a7..366653ce 100644 --- a/src/Api/Webpage.php +++ b/src/Api/Webpage.php @@ -11,7 +11,8 @@ final readonly class Webpage { - use Concerns\HasWaitCapabilities, + use Concerns\HasRoutes, + Concerns\HasWaitCapabilities, Concerns\InteractsWithElements, Concerns\InteractsWithFrames, Concerns\InteractsWithScreen, diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..e315be0b 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -101,6 +101,22 @@ public function execute(string $guid, string $method, array $params = [], array throw new ExpectationFailedException($message); } + if ( + isset($response['method']) && $response['method'] === '__create__' + && isset($response['params']['type']) + ) { + if ($response['params']['type'] === 'Request') { + Request::register(Request::fromArray($response['params'])); + + continue; + } + if ($response['params']['type'] === 'Route') { + Route::handle(Route::fromArray($response['params'])); + + continue; + } + } + yield $response; if ( @@ -112,6 +128,30 @@ public function execute(string $guid, string $method, array $params = [], array } } + /** + * Executes a method on the Playwright instance without expecting a response. + * Useful for nested execute calls which should not consume the outer loops messages. + * + * @param array $params + * @param array $meta + */ + public function executeWithoutResponse(string $guid, string $method, array $params = [], array $meta = []): void + { + assert($this->websocketConnection instanceof WebsocketConnection, 'WebSocket client is not connected.'); + + $requestId = uniqid(); + + $requestJson = (string) json_encode([ + 'id' => $requestId, + 'guid' => $guid, + 'method' => $method, + 'params' => ['timeout' => $this->timeout, ...$params], + 'metadata' => $meta, + ]); + + $this->websocketConnection->sendText($requestJson); + } + /** * Sets the timeout in milliseconds for requests. */ diff --git a/src/Playwright/Context.php b/src/Playwright/Context.php index 447d8582..fd8b2e6b 100644 --- a/src/Playwright/Context.php +++ b/src/Playwright/Context.php @@ -60,6 +60,16 @@ public function newPage(): Page return new Page($this, $pageGuid, $frameGuid); } + /** + * Sets network interception patterns + */ + public function setNetworkInterceptionPatterns($options): void + { + $response = $this->sendMessage('setNetworkInterceptionPatterns', $options); + + $this->processVoidResponse($response); + } + /** * Closes the browser context. */ diff --git a/src/Playwright/Playwright.php b/src/Playwright/Playwright.php index c0cffae1..9b5fe7d6 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -192,6 +192,9 @@ public static function shouldDebugAssertions(): bool */ public static function reset(): void { + Request::reset(); + Route::reset(); + foreach (self::$browserTypes as $browserType) { $browserType->reset(); } diff --git a/src/Playwright/Request.php b/src/Playwright/Request.php new file mode 100644 index 00000000..ea69b333 --- /dev/null +++ b/src/Playwright/Request.php @@ -0,0 +1,71 @@ + + * */ + private static array $requests = []; + + /** + * @param array $headers + */ + public function __construct( + public string $guid, + public string $frameGuid, + public string $url, + public string $resourceType, + public string $method, + public array $headers, + public bool $isNavigationRequest, + ) {} + + public static function fromArray(array $params): self + { + $headers = []; + foreach ($params['initializer']['headers'] as $header) { + $headers[$header['name']] = $header['value']; + } + + return new self( + $params['guid'], + frameGuid: $params['initializer']['frame']['guid'], + url: $params['initializer']['url'], + resourceType: $params['initializer']['resourceType'], + method: $params['initializer']['method'], + headers: $headers, + isNavigationRequest: $params['initializer']['isNavigationRequest'], + ); + } + + public static function register(self $request): void + { + self::$requests[$request->guid] = $request; + } + + public static function get(string $guid): self + { + return self::$requests[$guid]; + } + + public static function reset(): void + { + self::$requests = []; + } + + public function header(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + public function hasHeader(string $name): bool + { + return isset($this->headers[$name]); + } +} diff --git a/src/Playwright/Route.php b/src/Playwright/Route.php new file mode 100644 index 00000000..d3a7b1f8 --- /dev/null +++ b/src/Playwright/Route.php @@ -0,0 +1,91 @@ + + * */ + private static array $handlers = []; + + public function __construct( + private readonly string $guid, + private readonly Request $request + ) {} + + public static function fromArray(array $params): self + { + return new self($params['guid'], Request::get($params['initializer']['request']['guid'])); + } + + /** + * @param callable(Route): bool $handler + */ + public static function registerRouteHandler(string $pattern, callable $handler): void + { + self::$handlers[self::regexFromPattern($pattern)] = $handler; + } + + public static function handle(self $route): void + { + foreach (self::$handlers as $regex => $handler) { + if (preg_match($regex, $route->request()->url) === 1) { + $handler($route); + + return; + } + } + throw new RuntimeException( + sprintf('No route handler matched "%s".', $route->request()->url) + ); + } + + public static function regexFromPattern(string $pattern): string + { + // Escape regex characters. + $regex = preg_quote($pattern, '/'); + + // Replace escaped globs with regex equivalents. + $regex = str_replace('\*\*', '.*', $regex); // ** => anything + $regex = str_replace('\*', '[^\/]*', $regex); // * => anything except / + + return '/^'.$regex.'$/'; + } + + public static function reset(): void + { + self::$handlers = []; + } + + public function request(): Request + { + return $this->request; + } + + public function continue(array $params = ['isFallback' => true]): void + { + // Since this is called nested withing execute we don't want it to consume an actual response + Client::instance()->executeWithoutResponse($this->guid, 'continue', $params); + } + + public function abort(array $params = []): void + { + // Since this is called nested withing execute we don't want it to consume an actual response + Client::instance()->executeWithoutResponse($this->guid, 'abort', $params); + } + + public function fulfill(array $params = []): void + { + // Since this is called nested withing execute we don't want it to consume an actual response + Client::instance()->executeWithoutResponse($this->guid, 'fulfill', $params); + } +} diff --git a/tests/Browser/Webpage/RoutesTest.php b/tests/Browser/Webpage/RoutesTest.php new file mode 100644 index 00000000..5d47aed0 --- /dev/null +++ b/tests/Browser/Webpage/RoutesTest.php @@ -0,0 +1,66 @@ + ' + + '); + + Route::get('/message', fn (): string => 'continued'); + + $page = visit('/')->withRoute('**/message', fn (PlaywrightRoute $route) => $route->continue()); + + $page->assertSee('continued'); + $page->assertDontSee('aborted'); + $page->assertDontSee('fulfilled'); +}); + +it('may abort a request', function (): void { + Route::get('/', fn (): string => ' + + '); + + Route::get('/message', fn (): string => 'continued'); + + $page = visit('/')->withRoute('**/message', fn (PlaywrightRoute $route) => $route->abort()); + + $page->assertDontSee('continued'); + $page->assertSee('aborted'); + $page->assertDontSee('fulfilled'); +}); + +it('may fulfill a request', function (): void { + Route::get('/', fn (): string => ' + + '); + + Route::get('/message', fn (): string => 'continued'); + + $page = visit('/')->withRoute('**/message', fn (PlaywrightRoute $route) => $route->fulfill([ + 'status' => 200, + 'body' => 'fulfilled', + ])); + + $page->assertDontSee('continued'); + $page->assertDontSee('aborted'); + $page->assertSee('fulfilled'); +}); From 1777540f1b0c01a830cb9a209a0c639a157df10e Mon Sep 17 00:00:00 2001 From: Jonathan Mateman Date: Fri, 24 Jul 2026 11:14:33 +0200 Subject: [PATCH 2/3] Fixed race condition bugs --- src/Api/Concerns/HasRoutes.php | 2 +- src/Playwright/Playwright.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Api/Concerns/HasRoutes.php b/src/Api/Concerns/HasRoutes.php index d1722d23..a31c8456 100644 --- a/src/Api/Concerns/HasRoutes.php +++ b/src/Api/Concerns/HasRoutes.php @@ -19,6 +19,7 @@ public function route( string $pattern, callable $handler, ): void { + Route::registerRouteHandler($pattern, $handler); $this->page->context()->setNetworkInterceptionPatterns([ 'patterns' => [ [ @@ -26,6 +27,5 @@ public function route( ], ], ]); - Route::registerRouteHandler($pattern, $handler); } } diff --git a/src/Playwright/Playwright.php b/src/Playwright/Playwright.php index 9b5fe7d6..9006529f 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -192,12 +192,12 @@ public static function shouldDebugAssertions(): bool */ public static function reset(): void { - Request::reset(); - Route::reset(); - foreach (self::$browserTypes as $browserType) { $browserType->reset(); } + + Request::reset(); + Route::reset(); } /** From 423d1bf5e91032a62351cd1b433b713a2ab48f69 Mon Sep 17 00:00:00 2001 From: Jonathan Mateman Date: Fri, 24 Jul 2026 11:19:10 +0200 Subject: [PATCH 3/3] Lint --- src/Api/From.php | 2 +- src/Api/On.php | 2 +- src/Playwright/Context.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Api/From.php b/src/Api/From.php index b077b7ae..465a5be0 100644 --- a/src/Api/From.php +++ b/src/Api/From.php @@ -23,7 +23,7 @@ public function __construct( private Device $device, private string $url, private array $options, - private readonly array $routes, + private array $routes, ) { // } diff --git a/src/Api/On.php b/src/Api/On.php index 6455f468..28249edd 100644 --- a/src/Api/On.php +++ b/src/Api/On.php @@ -22,7 +22,7 @@ public function __construct( private Device $device, private string $url, private array $options, - private readonly array $routes, + private array $routes, ) { // } diff --git a/src/Playwright/Context.php b/src/Playwright/Context.php index fd8b2e6b..9df5e628 100644 --- a/src/Playwright/Context.php +++ b/src/Playwright/Context.php @@ -63,7 +63,7 @@ public function newPage(): Page /** * Sets network interception patterns */ - public function setNetworkInterceptionPatterns($options): void + public function setNetworkInterceptionPatterns(array $options): void { $response = $this->sendMessage('setNetworkInterceptionPatterns', $options);